From ff7945496e8d6d7fe7ae42bae02593b3557762e2 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 29 Jul 2020 20:14:26 -0400 Subject: [PATCH 1/3] wip - add autocomplete workaround for .text fields --- .../exceptions/builder/builder_entry_item.tsx | 12 +++- .../components/exceptions/builder/helpers.tsx | 57 ++++++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 5939a5a1b576eb..42f6ca1b2eae92 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -170,7 +170,11 @@ export const BuilderEntryItem: React.FC = ({ return ( = ({ return ( { +// if (field != null && selectedFieldIsTextType) { +// const keywordField = field.split('.').slice(-1).join('.'); + +// return field === name || name === keywordField; +// } else { +// return field != null && field === name; +// } +// }).reduce<{ foundField: IFieldType | null; foundKeywordField: IFieldType | null}>((acc, foundField) => { +// if (selectedFieldIsTextType && foundField.esTypes != null && foundField.esTypes.includes('keyword')) { +// return { +// ...acc, +// foundKeywordField: foundField, +// } +// } else { +// return { +// ...acc, +// foundField +// } +// } +// }, { foundField: null, foundKeywordField: null}); + /** * Formats the entry into one that is easily usable for the UI, most of the * complexity was introduced with nested fields @@ -95,11 +117,41 @@ export const getFormattedBuilderEntry = ( ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; + const selectedFieldIsTextType = field != null && field.split('.').slice(-1)[0] === 'text'; const [selectedField] = fields.filter(({ name }) => field != null && field === name); + const foundFields = fields + .filter(({ name }) => { + if (field != null && selectedFieldIsTextType) { + const fieldBits = field.split('.'); + const keywordField = fieldBits.slice(0, fieldBits.length - 1).join('.'); + + return field === name || name === keywordField; + } else { + return field != null && field === name; + } + }) + .reduce<{ foundField: IFieldType; foundKeywordField: IFieldType }>((acc, foundField) => { + if ( + selectedFieldIsTextType && + foundField.esTypes != null && + foundField.esTypes.includes('keyword') + ) { + return { + ...acc, + foundKeywordField: foundField, + }; + } else { + return { + ...acc, + foundField, + }; + } + }, {}); if (parent != null && parentIndex != null) { return { - field: selectedField, + field: foundFields.foundField, + correspondingKeywordField: foundFields.foundKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -108,7 +160,8 @@ export const getFormattedBuilderEntry = ( }; } else { return { - field: selectedField, + field: foundFields.foundField, + correspondingKeywordField: foundFields.foundKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: undefined, From ab3c2f76f487b918ed687dfe395321cd0936ce63 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 30 Jul 2020 13:20:30 -0400 Subject: [PATCH 2/3] updates autocomplete to remove react memory leak error --- .../autocomplete/field_value_match.test.tsx | 1 - .../autocomplete/field_value_match.tsx | 15 ++- .../field_value_match_any.test.tsx | 1 - .../autocomplete/field_value_match_any.tsx | 15 ++- .../use_field_value_autocomplete.test.ts | 17 +-- .../hooks/use_field_value_autocomplete.ts | 92 +++++++++------- .../builder/builder_entry_item.test.tsx | 70 ++++++++++++ .../builder/builder_exception_item.test.tsx | 24 ++++- .../exceptions/builder/helpers.test.tsx | 100 ++++++++++++++++++ .../components/exceptions/builder/helpers.tsx | 97 ++++++++--------- .../common/components/exceptions/types.ts | 1 + 11 files changed, 313 insertions(+), 120 deletions(-) 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 index 72467a62f57c1a..998ed1f3351c80 100644 --- 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 @@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchComponent', () => { 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 index 137f6803dc54e7..dfb3761bb34978 100644 --- 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 @@ -72,14 +72,13 @@ export const AutocompleteFieldMatchComponent: React.FC { - const signal = new AbortController().signal; - - updateSuggestions({ - fieldSelected: selectedField, - value: `${searchVal}`, - patterns: indexPattern, - signal, - }); + if (updateSuggestions != null) { + updateSuggestions({ + fieldSelected: selectedField, + value: searchVal, + patterns: indexPattern, + }); + } }; const isValid = useMemo( 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 index f3f0f2e2a44b1f..0a0281a9c4a51a 100644 --- 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 @@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchAnyComponent', () => { 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 index 5a15c1f7238dea..1952ef865e045a 100644 --- 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 @@ -65,14 +65,13 @@ export const AutocompleteFieldMatchAnyComponent: React.FC { - const signal = new AbortController().signal; - - updateSuggestions({ - fieldSelected: selectedField, - value: `${searchVal}`, - patterns: indexPattern, - signal, - }); + if (updateSuggestions != null) { + updateSuggestions({ + fieldSelected: selectedField, + value: searchVal, + patterns: indexPattern, + }); + } }; const onCreateOption = (option: string) => onChange([...(selectedValue || []), option]); 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 index def2a303f6038a..a76b50d11a8757 100644 --- 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 @@ -199,12 +199,17 @@ describe('useFieldValueAutocomplete', () => { await waitForNextUpdate(); await waitForNextUpdate(); - result.current[2]({ - fieldSelected: getField('@tags'), - value: 'hello', - patterns: stubIndexPatternWithFields, - signal: new AbortController().signal, - }); + expect(result.current[2]).not.toBeNull(); + + // Added check for typescripts sake, if null, + // would not reach below logic as test would stop above + if (result.current[2] != null) { + result.current[2]({ + fieldSelected: getField('@tags'), + value: 'hello', + patterns: stubIndexPatternWithFields, + }); + } await waitForNextUpdate(); 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 index 541c0a8d3fbae5..a53914da93f279 100644 --- 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 @@ -11,16 +11,13 @@ import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/d 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 -]; +type Func = (args: { + fieldSelected: IFieldType | undefined; + value: string | string[] | undefined; + patterns: IIndexPattern | undefined; +}) => void; + +export type UseFieldValueAutocompleteReturn = [boolean, string[], Func | null]; export interface UseFieldValueAutocompleteProps { selectedField: IFieldType | undefined; @@ -41,62 +38,77 @@ export const useFieldValueAutocomplete = ({ const { services } = useKibana(); const [isLoading, setIsLoading] = useState(false); const [suggestions, setSuggestions] = useState([]); - const updateSuggestions = useRef( - debounce( + const updateSuggestions = useRef(null); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + + const fetchSuggestions = debounce( async ({ fieldSelected, value, patterns, - signal, }: { fieldSelected: IFieldType | undefined; value: string | string[] | undefined; patterns: IIndexPattern | undefined; - signal: AbortSignal; }) => { - if (fieldSelected == null || patterns == null) { - return; - } + const inputValue: string | string[] = value ?? ''; + const userSuggestion: string = Array.isArray(inputValue) + ? inputValue[inputValue.length - 1] ?? '' + : inputValue; - setIsLoading(true); + try { + if (isSubscribed) { + if (fieldSelected == null || patterns == null) { + return; + } - // Fields of type boolean should only display two options - if (fieldSelected.type === 'boolean') { - setIsLoading(false); - setSuggestions(['true', 'false']); - return; - } + setIsLoading(true); - const newSuggestions = await services.data.autocomplete.getValueSuggestions({ - indexPattern: patterns, - field: fieldSelected, - query: '', - signal, - }); + // Fields of type boolean should only display two options + if (fieldSelected.type === 'boolean') { + setIsLoading(false); + setSuggestions(['true', 'false']); + return; + } - setIsLoading(false); - setSuggestions(newSuggestions); + const newSuggestions = await services.data.autocomplete.getValueSuggestions({ + indexPattern: patterns, + field: fieldSelected, + query: userSuggestion.trim(), + signal: abortCtrl.signal, + }); + + setIsLoading(false); + setSuggestions([...newSuggestions]); + } + } catch (error) { + if (isSubscribed) { + setSuggestions([]); + setIsLoading(false); + } + } }, 500 - ) - ); - - useEffect(() => { - const abortCtrl = new AbortController(); + ); if (operatorType !== OperatorTypeEnum.EXISTS) { - updateSuggestions.current({ + fetchSuggestions({ fieldSelected: selectedField, value: fieldValue, patterns: indexPattern, - signal: abortCtrl.signal, }); } + updateSuggestions.current = fetchSuggestions; + return (): void => { + isSubscribed = false; abortCtrl.abort(); }; - }, [updateSuggestions, selectedField, operatorType, fieldValue, indexPattern]); + }, [services.data.autocomplete, selectedField, operatorType, fieldValue, indexPattern]); return [isLoading, suggestions, updateSuggestions.current]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index 3dcc3eb5a8329f..0f54ec29cc5400 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -55,6 +55,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -81,6 +82,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -111,6 +113,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -143,6 +146,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -175,6 +179,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -207,6 +212,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -239,6 +245,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -271,6 +278,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -306,6 +314,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -331,6 +340,62 @@ describe('BuilderEntryItem', () => { ).toBeTruthy(); }); + test('it uses "correspondingKeywordField" if it exists', () => { + const wrapper = mount( + + ); + + expect( + wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField') + ).toEqual({ + name: 'extension', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }); + }); + test('it invokes "onChange" when new field is selected and resets operator and value fields', () => { const mockOnChange = jest.fn(); const wrapper = mount( @@ -342,6 +407,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -376,6 +442,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -410,6 +477,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -444,6 +512,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', @@ -478,6 +547,7 @@ describe('BuilderEntryItem', () => { nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }} indexPattern={{ id: '1234', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx index 584f0971a4193f..7624ce147abd9e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx @@ -9,13 +9,35 @@ import { ThemeProvider } from 'styled-components'; import { mount } from 'enzyme'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; -import { ExceptionListItemComponent } from './builder_exception_item'; +import { useKibana } from '../../../../common/lib/kibana'; import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { ExceptionListItemComponent } from './builder_exception_item'; + +jest.mock('../../../../common/lib/kibana'); + describe('ExceptionListItemComponent', () => { + const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); + + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + data: { + autocomplete: { + getValueSuggestions: getValueSuggestionsMock, + }, + }, + }, + }); + }); + + afterEach(() => { + getValueSuggestionsMock.mockClear(); + }); + describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => { const exceptionItem = { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index 17c94adf42648f..e1421a64ca6ada 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -41,6 +41,7 @@ import { getOperatorOptions, getUpdatedEntriesOnDelete, isEntryNested, + getCorrespondingKeywordField, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; @@ -57,6 +58,7 @@ const getMockBuilderEntry = (): FormattedBuilderEntry => ({ nested: undefined, parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }); const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ @@ -73,6 +75,7 @@ const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ parentIndex: 0, }, entryIndex: 0, + correspondingKeywordField: undefined, }); const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ @@ -82,9 +85,48 @@ const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ nested: 'parent', parent: undefined, entryIndex: 0, + correspondingKeywordField: undefined, }); describe('Exception builder helpers', () => { + describe('#getCorrespondingKeywordField', () => { + test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw.text', + }); + + expect(output).toEqual(getField('machine.os.raw')); + }); + + test('it returns undefined if "selectedFieldIsTextType" is false', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: 'machine.os.raw', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is empty string', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: '', + }); + + expect(output).toEqual(undefined); + }); + + test('it returns undefined if "selectedField" is undefined', () => { + const output = getCorrespondingKeywordField({ + fields, + selectedField: undefined, + }); + + expect(output).toEqual(undefined); + }); + }); + describe('#getFilteredIndexPatterns', () => { test('it returns nested fields that match parent value when "item.nested" is "child"', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); @@ -145,6 +187,56 @@ describe('Exception builder helpers', () => { }); describe('#getFormattedBuilderEntry', () => { + test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => { + const payloadIndexPattern: IIndexPattern = { + ...getMockIndexPattern(), + fields: [ + ...fields, + { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, + ], + }; + const payloadItem: BuilderEntry = { + ...getEntryMatchMock(), + field: 'machine.os.raw.text', + value: 'some os', + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + name: 'machine.os.raw.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: true, + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some os', + correspondingKeywordField: getField('machine.os.raw'), + }; + expect(output).toEqual(expected); + }); + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; @@ -188,6 +280,7 @@ describe('Exception builder helpers', () => { parentIndex: 1, }, value: 'some host name', + correspondingKeywordField: undefined, }; expect(output).toEqual(expected); }); @@ -218,6 +311,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }; expect(output).toEqual(expected); }); @@ -252,6 +346,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some host name', + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); @@ -281,6 +376,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }, { entryIndex: 1, @@ -298,6 +394,7 @@ describe('Exception builder helpers', () => { operator: isOneOfOperator, parent: undefined, value: ['some extension'], + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); @@ -333,6 +430,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: 'some ip', + correspondingKeywordField: undefined, }, { entryIndex: 1, @@ -347,6 +445,7 @@ describe('Exception builder helpers', () => { operator: isOperator, parent: undefined, value: undefined, + correspondingKeywordField: undefined, }, { entryIndex: 0, @@ -383,6 +482,7 @@ describe('Exception builder helpers', () => { parentIndex: 1, }, value: 'some host name', + correspondingKeywordField: undefined, }, ]; expect(output).toEqual(expected); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index ad6edd90ef7946..f8c501f6757036 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -75,27 +75,38 @@ export const getFilteredIndexPatterns = ( } }; -// fields.filter(({ name }) => { -// if (field != null && selectedFieldIsTextType) { -// const keywordField = field.split('.').slice(-1).join('.'); - -// return field === name || name === keywordField; -// } else { -// return field != null && field === name; -// } -// }).reduce<{ foundField: IFieldType | null; foundKeywordField: IFieldType | null}>((acc, foundField) => { -// if (selectedFieldIsTextType && foundField.esTypes != null && foundField.esTypes.includes('keyword')) { -// return { -// ...acc, -// foundKeywordField: foundField, -// } -// } else { -// return { -// ...acc, -// foundField -// } -// } -// }, { foundField: null, foundKeywordField: null}); +/** + * Fields of type 'text' do not generate autocomplete values, we want + * to find it's corresponding keyword type (if available) which does + * generate autocomplete values + * + * @param fields IFieldType fields + * @param selectedField the field name that was selected + * @param isTextType we only want a corresponding keyword field if + * the selected field is of type 'text' + * + */ +export const getCorrespondingKeywordField = ({ + fields, + selectedField, +}: { + fields: IFieldType[]; + selectedField: string | undefined; +}): IFieldType | undefined => { + const selectedFieldBits = + selectedField != null && selectedField !== '' ? selectedField.split('.') : []; + const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text'; + + if (selectedFieldIsTextType && selectedFieldBits.length > 0) { + const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.'); + const [foundKeywordField] = fields.filter( + ({ name }) => keywordField !== '' && keywordField === name + ); + return foundKeywordField; + } + + return undefined; +}; /** * Formats the entry into one that is easily usable for the UI, most of the @@ -117,41 +128,16 @@ export const getFormattedBuilderEntry = ( ): FormattedBuilderEntry => { const { fields } = indexPattern; const field = parent != null ? `${parent.field}.${item.field}` : item.field; - const selectedFieldIsTextType = field != null && field.split('.').slice(-1)[0] === 'text'; - const [selectedField] = fields.filter(({ name }) => field != null && field === name); - const foundFields = fields - .filter(({ name }) => { - if (field != null && selectedFieldIsTextType) { - const fieldBits = field.split('.'); - const keywordField = fieldBits.slice(0, fieldBits.length - 1).join('.'); - - return field === name || name === keywordField; - } else { - return field != null && field === name; - } - }) - .reduce<{ foundField: IFieldType; foundKeywordField: IFieldType }>((acc, foundField) => { - if ( - selectedFieldIsTextType && - foundField.esTypes != null && - foundField.esTypes.includes('keyword') - ) { - return { - ...acc, - foundKeywordField: foundField, - }; - } else { - return { - ...acc, - foundField, - }; - } - }, {}); + const [foundField] = fields.filter(({ name }) => field != null && field === name); + const correspondingKeywordField = getCorrespondingKeywordField({ + fields, + selectedField: field, + }); if (parent != null && parentIndex != null) { return { - field: foundFields.foundField, - correspondingKeywordField: foundFields.foundKeywordField, + field: foundField, + correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: 'child', @@ -160,8 +146,8 @@ export const getFormattedBuilderEntry = ( }; } else { return { - field: foundFields.foundField, - correspondingKeywordField: foundFields.foundKeywordField, + field: foundField, + correspondingKeywordField, operator: getExceptionOperatorSelect(item), value: getEntryValue(item), nested: undefined, @@ -220,6 +206,7 @@ export const getFormattedBuilderEntries = ( value: undefined, entryIndex: index, parent: undefined, + correspondingKeywordField: undefined, }; // User has selected to add a nested field, but not yet selected the field 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 83367e5b9e7399..9b7c68848c41fb 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 @@ -62,6 +62,7 @@ export interface FormattedBuilderEntry { nested: 'parent' | 'child' | undefined; entryIndex: number; parent: { parent: EntryNested; parentIndex: number } | undefined; + correspondingKeywordField: IFieldType | undefined; } export interface EmptyEntry { From 923d0aa786d64ae44261cc5a8175a1cdb7e9bc05 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 30 Jul 2020 14:18:41 -0400 Subject: [PATCH 3/3] udpate unit tests --- .../exceptions/builder/builder_entry_item.tsx | 4 +- .../exceptions/builder/helpers.test.tsx | 249 ++++++++++++++---- .../components/exceptions/builder/helpers.tsx | 24 +- .../components/exceptions/builder/index.tsx | 17 +- 4 files changed, 219 insertions(+), 75 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 42f6ca1b2eae92..3883a2fad2cf2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -95,7 +95,7 @@ export const BuilderEntryItem: React.FC = ({ const renderFieldInput = useCallback( (isFirst: boolean): JSX.Element => { - const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry); + const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry, listType); const comboBox = ( = ({ return comboBox; } }, - [handleFieldChange, indexPattern, entry] + [handleFieldChange, indexPattern, entry, listType] ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index e1421a64ca6ada..e8a5196a418d69 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -88,6 +88,33 @@ const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ correspondingKeywordField: undefined, }); +const mockEndpointFields = [ + { + name: 'file.path.text', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + name: 'file.Ext.code_signature.status', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'file.Ext.code_signature' } }, + }, +]; + +export const getEndpointField = (name: string) => + mockEndpointFields.find((field) => field.name === name) as IFieldType; + describe('Exception builder helpers', () => { describe('#getCorrespondingKeywordField', () => { test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => { @@ -128,61 +155,181 @@ describe('Exception builder helpers', () => { }); describe('#getFilteredIndexPatterns', () => { - test('it returns nested fields that match parent value when "item.nested" is "child"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); + describe('list type detections', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); - test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); - }); + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); - test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = { - ...getMockNestedParentBuilderEntry(), - field: undefined, - }; - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [ - { ...getField('nestedField.child') }, - { ...getField('nestedField.nestedChild.doublyNestedChild') }, - ], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'detection'); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); }); - test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { - const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); - const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); - const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); - const expected: IIndexPattern = { - fields: [...fields], - id: '1234', - title: 'logstash-*', - }; - expect(output).toEqual(expected); + describe('list type endpoint', () => { + let payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + + beforeAll(() => { + payloadIndexPattern = { + ...payloadIndexPattern, + fields: [...payloadIndexPattern.fields, ...mockEndpointFields], + }; + }); + + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + field: getEndpointField('file.Ext.code_signature.status'), + operator: isOperator, + value: 'some value', + nested: 'child', + parent: { + parent: { + ...getEntryNestedMock(), + field: 'file.Ext.code_signature', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }, + parentIndex: 0, + }, + entryIndex: 0, + correspondingKeywordField: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: { + ...getEndpointField('file.Ext.code_signature.status'), + name: 'file.Ext.code_signature', + esTypes: ['nested'], + }, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['nested'], + name: 'file.Ext.code_signature', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'file.Ext.code_signature', + }, + }, + type: 'string', + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [getEndpointField('file.Ext.code_signature.status')], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields that matched those in "exceptionable_fields.json" with no further filtering if "item.nested" is not "child" or "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint'); + const expected: IIndexPattern = { + fields: [ + { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'file.path.text', + readFromDocValues: false, + scripted: false, + searchable: true, + type: 'string', + }, + { + name: 'file.Ext.code_signature.status', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'file.Ext.code_signature' } }, + }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index f8c501f6757036..8585f58504e313 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -33,6 +33,8 @@ import { EmptyNestedEntry, } from '../types'; import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import exceptionableFields from '../exceptionable_fields.json'; /** * Returns filtered index patterns based on the field - if a user selects to @@ -45,13 +47,21 @@ import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; */ export const getFilteredIndexPatterns = ( patterns: IIndexPattern, - item: FormattedBuilderEntry + item: FormattedBuilderEntry, + type: ExceptionListType ): IIndexPattern => { + const indexPatterns = { + ...patterns, + fields: patterns.fields.filter(({ name }) => + type === 'endpoint' ? exceptionableFields.includes(name) : true + ), + }; + if (item.nested === 'child' && item.parent != null) { // when user has selected a nested entry, only fields with the common parent are shown return { - ...patterns, - fields: patterns.fields.filter( + ...indexPatterns, + fields: indexPatterns.fields.filter( (field) => field.subType != null && field.subType.nested != null && @@ -61,17 +71,17 @@ export const getFilteredIndexPatterns = ( }; } else if (item.nested === 'parent' && item.field != null) { // when user has selected a nested entry, right above it we show the common parent - return { ...patterns, fields: [item.field] }; + return { ...indexPatterns, fields: [item.field] }; } else if (item.nested === 'parent' && item.field == null) { // when user selects to add a nested entry, only nested fields are shown as options return { - ...patterns, - fields: patterns.fields.filter( + ...indexPatterns, + fields: indexPatterns.fields.filter( (field) => field.subType != null && field.subType.nested != null ), }; } else { - return patterns; + return indexPatterns; } }; 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 index 734434484fb4cc..b82607a541aaac 100644 --- 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 @@ -3,7 +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 React, { useCallback, useEffect, useMemo, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; @@ -29,8 +29,6 @@ import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry, } from './helpers'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import exceptionableFields from '../exceptionable_fields.json'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; @@ -244,17 +242,6 @@ export const ExceptionBuilder = ({ setUpdateExceptions([...exceptions, { ...newException }]); }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]); - // Filters index pattern fields by exceptionable fields if list type is endpoint - const filterIndexPatterns = useMemo((): IIndexPattern => { - if (listType === 'endpoint') { - return { - ...indexPatterns, - fields: indexPatterns.fields.filter(({ name }) => exceptionableFields.includes(name)), - }; - } - return indexPatterns; - }, [indexPatterns, listType]); - // 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 @@ -368,7 +355,7 @@ export const ExceptionBuilder = ({ key={getExceptionListItemId(exceptionListItem, index)} exceptionItem={exceptionListItem} exceptionId={getExceptionListItemId(exceptionListItem, index)} - indexPattern={filterIndexPatterns} + indexPattern={indexPatterns} listType={listType} addNested={addNested} exceptionItemIndex={index}