Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Exceptions] - Update add/edit exception flyouts #143127

Merged
merged 7 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,30 @@ describe('FieldComponent', () => {
expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('_source')
);
});

it('it allows custom user input if "acceptsCustomOptions" is "true"', async () => {
const mockOnChange = jest.fn();
const wrapper = render(
<FieldComponent
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
}}
isClearable={false}
isDisabled={false}
isLoading={false}
onChange={mockOnChange}
placeholder="Placeholder text"
selectedField={undefined}
acceptsCustomOptions
/>
);

const fieldAutocompleteComboBox = wrapper.getByTestId('comboBoxSearchInput');
fireEvent.change(fieldAutocompleteComboBox, { target: { value: 'custom' } });
await waitFor(() =>
expect(wrapper.getByTestId('fieldAutocompleteComboBox')).toHaveTextContent('custom')
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,18 @@ describe('useField', () => {
]);
});
});
it('should invoke onChange with custom option if one is sent', () => {
const { result } = renderHook(() => useField({ indexPattern, onChange: onChangeMock }));
act(() => {
result.current.handleCreateCustomOption('madeUpField');
expect(onChangeMock).toHaveBeenCalledWith([
{
name: 'madeUpField',
type: 'text',
},
]);
});
});
});

describe('fieldWidth', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/kbn-securitysolution-autocomplete/src/field/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const FieldComponent: React.FC<FieldProps> = ({
onChange,
placeholder,
selectedField,
acceptsCustomOptions = false,
}): JSX.Element => {
const {
isInvalid,
Expand All @@ -35,6 +36,7 @@ export const FieldComponent: React.FC<FieldProps> = ({
renderFields,
handleTouch,
handleValuesChange,
handleCreateCustomOption,
} = useField({
indexPattern,
fieldTypeFilter,
Expand All @@ -43,6 +45,29 @@ export const FieldComponent: React.FC<FieldProps> = ({
fieldInputWidth,
onChange,
});

if (acceptsCustomOptions) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: might be possible to remove this if statement and either conditionally pass the props or conditionally modify a props object.

return (
<EuiComboBox
placeholder={placeholder}
options={comboOptions}
selectedOptions={selectedComboOptions}
onChange={handleValuesChange}
isLoading={isLoading}
isDisabled={isDisabled}
isClearable={isClearable}
isInvalid={isInvalid}
onFocus={handleTouch}
singleSelection={AS_PLAIN_TEXT}
data-test-subj="fieldAutocompleteComboBox"
style={fieldWidth}
onCreateOption={handleCreateCustomOption}
customOptionText="Add {searchValue} as your occupation"
fullWidth
/>
);
}

return (
<EuiComboBox
placeholder={placeholder}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface FieldProps extends FieldBaseProps {
isDisabled: boolean;
isLoading: boolean;
placeholder: string;
acceptsCustomOptions?: boolean;
}
export interface FieldBaseProps {
indexPattern: DataViewBase | undefined;
Expand Down
27 changes: 23 additions & 4 deletions packages/kbn-securitysolution-autocomplete/src/field/use_field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,15 @@ export const useField = ({
}: FieldBaseProps) => {
const [touched, setIsTouched] = useState(false);

const { availableFields, selectedFields } = useMemo(
() => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter),
[indexPattern, fieldTypeFilter, selectedField]
);
const [customOption, setCustomOption] = useState<DataViewFieldBase | null>(null);

const { availableFields, selectedFields } = useMemo(() => {
const indexPatternsToUse =
customOption != null && indexPattern != null
? { ...indexPattern, fields: [...indexPattern?.fields, customOption] }
: indexPattern;
return getComboBoxFields(indexPatternsToUse, selectedField, fieldTypeFilter);
}, [indexPattern, fieldTypeFilter, selectedField, customOption]);

const { comboOptions, labels, selectedComboOptions, disabledLabelTooltipTexts } = useMemo(
() => getComboBoxProps({ availableFields, selectedFields }),
Expand All @@ -117,6 +122,19 @@ export const useField = ({
[availableFields, labels, onChange]
);

const handleCreateCustomOption = useCallback(
(val: string) => {
const normalizedSearchValue = val.trim().toLowerCase();

if (!normalizedSearchValue) {
return;
}
setCustomOption({ name: val, type: 'text' });
onChange([{ name: val, type: 'text' }]);
},
[onChange]
);

const handleTouch = useCallback((): void => {
setIsTouched(true);
}, [setIsTouched]);
Expand Down Expand Up @@ -161,5 +179,6 @@ export const useField = ({
renderFields,
handleTouch,
handleValuesChange,
handleCreateCustomOption,
};
};
136 changes: 76 additions & 60 deletions packages/kbn-securitysolution-list-utils/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
EntriesArray,
Entry,
EntryNested,
ExceptionListItemSchema,
ExceptionListType,
ListSchema,
NamespaceType,
Expand All @@ -27,6 +26,8 @@ import {
entry,
exceptionListItemSchema,
nestedEntryItem,
CreateRuleExceptionListItemSchema,
createRuleExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import {
DataViewBase,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
EmptyEntry,
EmptyNestedEntry,
ExceptionsBuilderExceptionItem,
ExceptionsBuilderReturnExceptionItem,
FormattedBuilderEntry,
OperatorOption,
} from '../types';
Expand All @@ -65,59 +67,60 @@ export const isEntryNested = (item: BuilderEntry): item is EntryNested => {

export const filterExceptionItems = (
exceptions: ExceptionsBuilderExceptionItem[]
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
return exceptions.reduce<Array<ExceptionListItemSchema | CreateExceptionListItemSchema>>(
(acc, exception) => {
const entries = exception.entries.reduce<BuilderEntry[]>((nestedAcc, singleEntry) => {
const strippedSingleEntry = removeIdFromItem(singleEntry);

if (entriesNested.is(strippedSingleEntry)) {
const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => {
const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry);
const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem);
return validatedNestedEntry != null;
});
const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) =>
removeIdFromItem(singleNestedEntry)
);

const [validatedNestedEntry] = validate(
{ ...strippedSingleEntry, entries: noIdNestedEntries },
entriesNested
);

if (validatedNestedEntry != null) {
return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }];
}
return nestedAcc;
} else {
const [validatedEntry] = validate(strippedSingleEntry, entry);

if (validatedEntry != null) {
return [...nestedAcc, singleEntry];
}
return nestedAcc;
): ExceptionsBuilderReturnExceptionItem[] => {
return exceptions.reduce<ExceptionsBuilderReturnExceptionItem[]>((acc, exception) => {
const entries = exception.entries.reduce<BuilderEntry[]>((nestedAcc, singleEntry) => {
const strippedSingleEntry = removeIdFromItem(singleEntry);
if (entriesNested.is(strippedSingleEntry)) {
const nestedEntriesArray = strippedSingleEntry.entries.filter((singleNestedEntry) => {
const noIdSingleNestedEntry = removeIdFromItem(singleNestedEntry);
const [validatedNestedEntry] = validate(noIdSingleNestedEntry, nestedEntryItem);
return validatedNestedEntry != null;
});
const noIdNestedEntries = nestedEntriesArray.map((singleNestedEntry) =>
removeIdFromItem(singleNestedEntry)
);

const [validatedNestedEntry] = validate(
{ ...strippedSingleEntry, entries: noIdNestedEntries },
entriesNested
);

if (validatedNestedEntry != null) {
return [...nestedAcc, { ...singleEntry, entries: nestedEntriesArray }];
}
}, []);

if (entries.length === 0) {
return acc;
return nestedAcc;
} else {
const [validatedEntry] = validate(strippedSingleEntry, entry);
if (validatedEntry != null) {
return [...nestedAcc, singleEntry];
}
return nestedAcc;
}
}, []);

const item = { ...exception, entries };
if (entries.length === 0) {
return acc;
}

if (exceptionListItemSchema.is(item)) {
return [...acc, item];
} else if (createExceptionListItemSchema.is(item)) {
const { meta, ...rest } = item;
const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined };
return [...acc, itemSansMetaId];
} else {
return acc;
}
},
[]
);
const item = { ...exception, entries };

if (exceptionListItemSchema.is(item)) {
return [...acc, item];
} else if (
createExceptionListItemSchema.is(item) ||
createRuleExceptionListItemSchema.is(item)
) {
const { meta, ...rest } = item;
const itemSansMetaId: CreateExceptionListItemSchema | CreateRuleExceptionListItemSchema = {
...rest,
meta: undefined,
};
return [...acc, itemSansMetaId];
} else {
return acc;
}
}, []);
};

export const addIdToEntries = (entries: EntriesArray): EntriesArray => {
Expand All @@ -136,15 +139,15 @@ export const addIdToEntries = (entries: EntriesArray): EntriesArray => {
export const getNewExceptionItem = ({
listId,
namespaceType,
ruleName,
name,
}: {
listId: string | undefined;
namespaceType: NamespaceType | undefined;
ruleName: string;
name: string;
}): CreateExceptionListItemBuilderSchema => {
return {
comments: [],
description: 'Exception list item',
description: `Exception list item`,
entries: addIdToEntries([
{
field: '',
Expand All @@ -158,7 +161,7 @@ export const getNewExceptionItem = ({
meta: {
temporaryUuid: uuid.v4(),
},
name: `${ruleName} - exception list item`,
name,
namespace_type: namespaceType,
tags: [],
type: 'simple',
Expand Down Expand Up @@ -769,13 +772,15 @@ export const getCorrespondingKeywordField = ({
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
* @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not
*/
export const getFormattedBuilderEntry = (
indexPattern: DataViewBase,
item: BuilderEntry,
itemIndex: number,
parent: EntryNested | undefined,
parentIndex: number | undefined
parentIndex: number | undefined,
allowCustomFieldOptions: boolean
): FormattedBuilderEntry => {
const { fields } = indexPattern;
const field = parent != null ? `${parent.field}.${item.field}` : item.field;
Expand All @@ -800,10 +805,14 @@ export const getFormattedBuilderEntry = (
value: getEntryValue(item),
};
} else {
const fieldToUse = allowCustomFieldOptions
? foundField ?? { name: item.field, type: 'keyword' }
: foundField;

return {
correspondingKeywordField,
entryIndex: itemIndex,
field: foundField,
field: fieldToUse,
id: item.id != null ? item.id : `${itemIndex}`,
nested: undefined,
operator: getExceptionOperatorSelect(item),
Expand All @@ -819,15 +828,15 @@ export const getFormattedBuilderEntry = (
*
* @param patterns DataViewBase containing available fields on rule index
* @param entries exception item entries
* @param addNested boolean noting whether or not UI is currently
* set to add a nested field
* @param allowCustomFieldOptions determines if field must be found to match in indexPattern or not
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
*/
export const getFormattedBuilderEntries = (
indexPattern: DataViewBase,
entries: BuilderEntry[],
allowCustomFieldOptions: boolean,
parent?: EntryNested,
parentIndex?: number
): FormattedBuilderEntry[] => {
Expand All @@ -839,7 +848,8 @@ export const getFormattedBuilderEntries = (
item,
index,
parent,
parentIndex
parentIndex,
allowCustomFieldOptions
);
return [...acc, newItemEntry];
} else {
Expand Down Expand Up @@ -869,7 +879,13 @@ export const getFormattedBuilderEntries = (
}

if (isEntryNested(item)) {
const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index);
const nestedItems = getFormattedBuilderEntries(
indexPattern,
item.entries,
allowCustomFieldOptions,
item,
index
);

return [...acc, parentEntry, ...nestedItems];
}
Expand Down
Loading