Skip to content

Commit 85a9c22

Browse files
committed
[Security Solution][Exceptions] Adds autocomplete workaround for .text fields (#73761)
## Summary This PR provides a workaround for the autocomplete service not providing suggestions when the selected field is `someField.text`. As endpoint exceptions will be largely using `.text` for now, wanted to still provide the autocomplete service. Updates to the autocomplete components were done after seeing some React errors that were popping up related to memory leaks. This is due to the use of `debounce` I believe. The calls were still executed even after the builder component was unmounted. This resulted in the subsequent calls from the autocomplete service not always going through (sometimes being canceled) when reopening the builder. Moved the filtering of endpoint fields to occur in the existing helper function so that we would still have access to the corresponding `keyword` field of `text` fields when formatting the entries for the builder.
1 parent 36ad1cb commit 85a9c22

File tree

13 files changed

+540
-142
lines changed

13 files changed

+540
-142
lines changed

x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchComponent', () => {
232232
fields,
233233
},
234234
value: 'value 1',
235-
signal: new AbortController().signal,
236235
});
237236
});
238237
});

x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,13 @@ export const AutocompleteFieldMatchComponent: React.FC<AutocompleteFieldMatchPro
7272
};
7373

7474
const onSearchChange = (searchVal: string): void => {
75-
const signal = new AbortController().signal;
76-
77-
updateSuggestions({
78-
fieldSelected: selectedField,
79-
value: `${searchVal}`,
80-
patterns: indexPattern,
81-
signal,
82-
});
75+
if (updateSuggestions != null) {
76+
updateSuggestions({
77+
fieldSelected: selectedField,
78+
value: searchVal,
79+
patterns: indexPattern,
80+
});
81+
}
8382
};
8483

8584
const isValid = useMemo(

x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,6 @@ describe('AutocompleteFieldMatchAnyComponent', () => {
232232
fields,
233233
},
234234
value: 'value 1',
235-
signal: new AbortController().signal,
236235
});
237236
});
238237
});

x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,13 @@ export const AutocompleteFieldMatchAnyComponent: React.FC<AutocompleteFieldMatch
6565
};
6666

6767
const onSearchChange = (searchVal: string) => {
68-
const signal = new AbortController().signal;
69-
70-
updateSuggestions({
71-
fieldSelected: selectedField,
72-
value: `${searchVal}`,
73-
patterns: indexPattern,
74-
signal,
75-
});
68+
if (updateSuggestions != null) {
69+
updateSuggestions({
70+
fieldSelected: selectedField,
71+
value: searchVal,
72+
patterns: indexPattern,
73+
});
74+
}
7675
};
7776

7877
const onCreateOption = (option: string) => onChange([...(selectedValue || []), option]);

x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,17 @@ describe('useFieldValueAutocomplete', () => {
199199
await waitForNextUpdate();
200200
await waitForNextUpdate();
201201

202-
result.current[2]({
203-
fieldSelected: getField('@tags'),
204-
value: 'hello',
205-
patterns: stubIndexPatternWithFields,
206-
signal: new AbortController().signal,
207-
});
202+
expect(result.current[2]).not.toBeNull();
203+
204+
// Added check for typescripts sake, if null,
205+
// would not reach below logic as test would stop above
206+
if (result.current[2] != null) {
207+
result.current[2]({
208+
fieldSelected: getField('@tags'),
209+
value: 'hello',
210+
patterns: stubIndexPatternWithFields,
211+
});
212+
}
208213

209214
await waitForNextUpdate();
210215

x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,13 @@ import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/d
1111
import { useKibana } from '../../../../common/lib/kibana';
1212
import { OperatorTypeEnum } from '../../../../lists_plugin_deps';
1313

14-
export type UseFieldValueAutocompleteReturn = [
15-
boolean,
16-
string[],
17-
(args: {
18-
fieldSelected: IFieldType | undefined;
19-
value: string | string[] | undefined;
20-
patterns: IIndexPattern | undefined;
21-
signal: AbortSignal;
22-
}) => void
23-
];
14+
type Func = (args: {
15+
fieldSelected: IFieldType | undefined;
16+
value: string | string[] | undefined;
17+
patterns: IIndexPattern | undefined;
18+
}) => void;
19+
20+
export type UseFieldValueAutocompleteReturn = [boolean, string[], Func | null];
2421

2522
export interface UseFieldValueAutocompleteProps {
2623
selectedField: IFieldType | undefined;
@@ -41,62 +38,77 @@ export const useFieldValueAutocomplete = ({
4138
const { services } = useKibana();
4239
const [isLoading, setIsLoading] = useState(false);
4340
const [suggestions, setSuggestions] = useState<string[]>([]);
44-
const updateSuggestions = useRef(
45-
debounce(
41+
const updateSuggestions = useRef<Func | null>(null);
42+
43+
useEffect(() => {
44+
let isSubscribed = true;
45+
const abortCtrl = new AbortController();
46+
47+
const fetchSuggestions = debounce(
4648
async ({
4749
fieldSelected,
4850
value,
4951
patterns,
50-
signal,
5152
}: {
5253
fieldSelected: IFieldType | undefined;
5354
value: string | string[] | undefined;
5455
patterns: IIndexPattern | undefined;
55-
signal: AbortSignal;
5656
}) => {
57-
if (fieldSelected == null || patterns == null) {
58-
return;
59-
}
57+
const inputValue: string | string[] = value ?? '';
58+
const userSuggestion: string = Array.isArray(inputValue)
59+
? inputValue[inputValue.length - 1] ?? ''
60+
: inputValue;
6061

61-
setIsLoading(true);
62+
try {
63+
if (isSubscribed) {
64+
if (fieldSelected == null || patterns == null) {
65+
return;
66+
}
6267

63-
// Fields of type boolean should only display two options
64-
if (fieldSelected.type === 'boolean') {
65-
setIsLoading(false);
66-
setSuggestions(['true', 'false']);
67-
return;
68-
}
68+
setIsLoading(true);
6969

70-
const newSuggestions = await services.data.autocomplete.getValueSuggestions({
71-
indexPattern: patterns,
72-
field: fieldSelected,
73-
query: '',
74-
signal,
75-
});
70+
// Fields of type boolean should only display two options
71+
if (fieldSelected.type === 'boolean') {
72+
setIsLoading(false);
73+
setSuggestions(['true', 'false']);
74+
return;
75+
}
7676

77-
setIsLoading(false);
78-
setSuggestions(newSuggestions);
77+
const newSuggestions = await services.data.autocomplete.getValueSuggestions({
78+
indexPattern: patterns,
79+
field: fieldSelected,
80+
query: userSuggestion.trim(),
81+
signal: abortCtrl.signal,
82+
});
83+
84+
setIsLoading(false);
85+
setSuggestions([...newSuggestions]);
86+
}
87+
} catch (error) {
88+
if (isSubscribed) {
89+
setSuggestions([]);
90+
setIsLoading(false);
91+
}
92+
}
7993
},
8094
500
81-
)
82-
);
83-
84-
useEffect(() => {
85-
const abortCtrl = new AbortController();
95+
);
8696

8797
if (operatorType !== OperatorTypeEnum.EXISTS) {
88-
updateSuggestions.current({
98+
fetchSuggestions({
8999
fieldSelected: selectedField,
90100
value: fieldValue,
91101
patterns: indexPattern,
92-
signal: abortCtrl.signal,
93102
});
94103
}
95104

105+
updateSuggestions.current = fetchSuggestions;
106+
96107
return (): void => {
108+
isSubscribed = false;
97109
abortCtrl.abort();
98110
};
99-
}, [updateSuggestions, selectedField, operatorType, fieldValue, indexPattern]);
111+
}, [services.data.autocomplete, selectedField, operatorType, fieldValue, indexPattern]);
100112

101113
return [isLoading, suggestions, updateSuggestions.current];
102114
};

x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('BuilderEntryItem', () => {
5555
nested: undefined,
5656
parent: undefined,
5757
entryIndex: 0,
58+
correspondingKeywordField: undefined,
5859
}}
5960
indexPattern={{
6061
id: '1234',
@@ -81,6 +82,7 @@ describe('BuilderEntryItem', () => {
8182
nested: undefined,
8283
parent: undefined,
8384
entryIndex: 0,
85+
correspondingKeywordField: undefined,
8486
}}
8587
indexPattern={{
8688
id: '1234',
@@ -111,6 +113,7 @@ describe('BuilderEntryItem', () => {
111113
nested: undefined,
112114
parent: undefined,
113115
entryIndex: 0,
116+
correspondingKeywordField: undefined,
114117
}}
115118
indexPattern={{
116119
id: '1234',
@@ -143,6 +146,7 @@ describe('BuilderEntryItem', () => {
143146
nested: undefined,
144147
parent: undefined,
145148
entryIndex: 0,
149+
correspondingKeywordField: undefined,
146150
}}
147151
indexPattern={{
148152
id: '1234',
@@ -175,6 +179,7 @@ describe('BuilderEntryItem', () => {
175179
nested: undefined,
176180
parent: undefined,
177181
entryIndex: 0,
182+
correspondingKeywordField: undefined,
178183
}}
179184
indexPattern={{
180185
id: '1234',
@@ -207,6 +212,7 @@ describe('BuilderEntryItem', () => {
207212
nested: undefined,
208213
parent: undefined,
209214
entryIndex: 0,
215+
correspondingKeywordField: undefined,
210216
}}
211217
indexPattern={{
212218
id: '1234',
@@ -239,6 +245,7 @@ describe('BuilderEntryItem', () => {
239245
nested: undefined,
240246
parent: undefined,
241247
entryIndex: 0,
248+
correspondingKeywordField: undefined,
242249
}}
243250
indexPattern={{
244251
id: '1234',
@@ -271,6 +278,7 @@ describe('BuilderEntryItem', () => {
271278
nested: undefined,
272279
parent: undefined,
273280
entryIndex: 0,
281+
correspondingKeywordField: undefined,
274282
}}
275283
indexPattern={{
276284
id: '1234',
@@ -306,6 +314,7 @@ describe('BuilderEntryItem', () => {
306314
nested: undefined,
307315
parent: undefined,
308316
entryIndex: 0,
317+
correspondingKeywordField: undefined,
309318
}}
310319
indexPattern={{
311320
id: '1234',
@@ -331,6 +340,62 @@ describe('BuilderEntryItem', () => {
331340
).toBeTruthy();
332341
});
333342

343+
test('it uses "correspondingKeywordField" if it exists', () => {
344+
const wrapper = mount(
345+
<BuilderEntryItem
346+
entry={{
347+
field: {
348+
name: 'extension.text',
349+
type: 'string',
350+
esTypes: ['text'],
351+
count: 0,
352+
scripted: false,
353+
searchable: false,
354+
aggregatable: false,
355+
readFromDocValues: true,
356+
},
357+
operator: isOneOfOperator,
358+
value: ['1234'],
359+
nested: undefined,
360+
parent: undefined,
361+
entryIndex: 0,
362+
correspondingKeywordField: {
363+
name: 'extension',
364+
type: 'string',
365+
esTypes: ['keyword'],
366+
count: 0,
367+
scripted: false,
368+
searchable: true,
369+
aggregatable: true,
370+
readFromDocValues: true,
371+
},
372+
}}
373+
indexPattern={{
374+
id: '1234',
375+
title: 'logstash-*',
376+
fields,
377+
}}
378+
showLabel={false}
379+
listType="detection"
380+
addNested={false}
381+
onChange={jest.fn()}
382+
/>
383+
);
384+
385+
expect(
386+
wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField')
387+
).toEqual({
388+
name: 'extension',
389+
type: 'string',
390+
esTypes: ['keyword'],
391+
count: 0,
392+
scripted: false,
393+
searchable: true,
394+
aggregatable: true,
395+
readFromDocValues: true,
396+
});
397+
});
398+
334399
test('it invokes "onChange" when new field is selected and resets operator and value fields', () => {
335400
const mockOnChange = jest.fn();
336401
const wrapper = mount(
@@ -342,6 +407,7 @@ describe('BuilderEntryItem', () => {
342407
nested: undefined,
343408
parent: undefined,
344409
entryIndex: 0,
410+
correspondingKeywordField: undefined,
345411
}}
346412
indexPattern={{
347413
id: '1234',
@@ -376,6 +442,7 @@ describe('BuilderEntryItem', () => {
376442
nested: undefined,
377443
parent: undefined,
378444
entryIndex: 0,
445+
correspondingKeywordField: undefined,
379446
}}
380447
indexPattern={{
381448
id: '1234',
@@ -410,6 +477,7 @@ describe('BuilderEntryItem', () => {
410477
nested: undefined,
411478
parent: undefined,
412479
entryIndex: 0,
480+
correspondingKeywordField: undefined,
413481
}}
414482
indexPattern={{
415483
id: '1234',
@@ -444,6 +512,7 @@ describe('BuilderEntryItem', () => {
444512
nested: undefined,
445513
parent: undefined,
446514
entryIndex: 0,
515+
correspondingKeywordField: undefined,
447516
}}
448517
indexPattern={{
449518
id: '1234',
@@ -478,6 +547,7 @@ describe('BuilderEntryItem', () => {
478547
nested: undefined,
479548
parent: undefined,
480549
entryIndex: 0,
550+
correspondingKeywordField: undefined,
481551
}}
482552
indexPattern={{
483553
id: '1234',

0 commit comments

Comments
 (0)