mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution][Exceptions][Builder] -Move exception builder entry item exceptions ui over to lists (#94515)
## Summary Beginning to move the exceptions UI out of the security solution plugin and into the lists plugin. In order to keep PRs (relatively) small, I plan to move single components at a time. This should also then help more easily pinpoint the source of any issues that come up along the way. The next couple PRs will focus on the exception builder. This one in particular is focused on moving over the `BuilderEntryItem` which deals with rendering the individual exception item entries. An entry can be of type `match`, `match_any`, `list`, `exists`, or `nested`. The component makes use of the autocomplete fields which use the index patterns to display possible fields and field values. One of the decisions made in this PR was to have consumers of the `BuilderEntryItem` pass through the autocomplete service as opposed to the `lists` plugin adding it as a dependency. The reason being that it is likely that plugins using the lists plugin will already be consuming either the data plugin or if alerting takes exceptions in, then they'll be consuming alerting. In an effort to avoid some possible icky circular dependency issues, though it best to make the service passed in, as we had already been doing with the hooks in the `lists` plugin.
This commit is contained in:
parent
31ea160fc7
commit
2aae753c54
38 changed files with 5660 additions and 1542 deletions
8
x-pack/plugins/lists/public/common/empty_value.ts
Normal file
8
x-pack/plugins/lists/public/common/empty_value.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const getEmptyValue = (): string => '—';
|
|
@ -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
|
||||
<FieldComponent
|
||||
placeholder={i18n.FIELD_PLACEHOLDER}
|
||||
indexPattern={indexPattern}
|
||||
selectedField={selectedField}
|
||||
isLoading={isLoading}
|
||||
isClearable={isClearable}
|
||||
onChange={handleFieldChange}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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
|
||||
<OperatorComponent
|
||||
placeholder={i18n.OPERATOR_PLACEHOLDER}
|
||||
selectedField={selectedField}
|
||||
operator={selectedOperator}
|
||||
isDisabled={iDisabled}
|
||||
isLoading={isLoading}
|
||||
isClearable={isClearable}
|
||||
onChange={handleOperatorChange}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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
|
||||
<AutocompleteFieldExistsComponent placeholder={i18n.EXISTS_VALUE_PLACEHOLDER} />
|
||||
```
|
||||
|
||||
## 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
|
||||
<AutocompleteFieldListsComponent
|
||||
selectedField={selectedField}
|
||||
placeholder={i18n.FIELD_LISTS_PLACEHOLDER}
|
||||
selectedValue={id}
|
||||
isLoading={isLoading}
|
||||
isDisabled={iDisabled}
|
||||
isClearable={isClearable}
|
||||
onChange={handleFieldListValueChange}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder={i18n.FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={selectedField}
|
||||
selectedValue={value}
|
||||
isDisabled={iDisabled}
|
||||
isLoading={isLoading}
|
||||
isClearable={isClearable}
|
||||
indexPattern={indexPattern}
|
||||
onChange={handleFieldMatchValueChange}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder={i18n.FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={selectedField}
|
||||
selectedValue={values}
|
||||
isDisabled={false}
|
||||
isLoading={isLoading}
|
||||
isClearable={false}
|
||||
indexPattern={indexPattern}
|
||||
onChange={handleFieldMatchAnyValueChange}
|
||||
/>
|
||||
```
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
|
||||
import { FieldComponent } from './field';
|
||||
|
||||
describe('FieldComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<FieldComponent
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
isLoading={false}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<FieldComponent
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={true}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<FieldComponent
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected field', () => {
|
||||
const wrapper = mount(
|
||||
<FieldComponent
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<FieldComponent
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
((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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
import { getGenericComboBoxProps } from './helpers';
|
||||
import { GetGenericComboBoxPropsReturn } from './types';
|
||||
|
||||
const AS_PLAIN_TEXT = { asPlainText: true };
|
||||
|
||||
interface OperatorProps {
|
||||
fieldInputWidth?: number;
|
||||
fieldTypeFilter?: string[];
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
isClearable: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
isRequired?: boolean;
|
||||
onChange: (a: IFieldType[]) => void;
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
}
|
||||
|
||||
export const FieldComponent: React.FC<OperatorProps> = ({
|
||||
fieldInputWidth,
|
||||
fieldTypeFilter = [],
|
||||
indexPattern,
|
||||
isClearable = false,
|
||||
isDisabled = false,
|
||||
isLoading = false,
|
||||
isRequired = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
selectedField,
|
||||
}): JSX.Element => {
|
||||
const [touched, setIsTouched] = useState(false);
|
||||
|
||||
const { availableFields, selectedFields } = useMemo(
|
||||
() => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter),
|
||||
[indexPattern, selectedField, fieldTypeFilter]
|
||||
);
|
||||
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
() => getComboBoxProps({ availableFields, selectedFields }),
|
||||
[availableFields, selectedFields]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: IFieldType[] = newOptions.map(
|
||||
({ label }) => availableFields[labels.indexOf(label)]
|
||||
);
|
||||
onChange(newValues);
|
||||
},
|
||||
[availableFields, labels, onChange]
|
||||
);
|
||||
|
||||
const handleTouch = useCallback((): void => {
|
||||
setIsTouched(true);
|
||||
}, [setIsTouched]);
|
||||
|
||||
const fieldWidth = useMemo(() => {
|
||||
return fieldInputWidth ? { width: `${fieldInputWidth}px` } : {};
|
||||
}, [fieldInputWidth]);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
isClearable={isClearable}
|
||||
isInvalid={isRequired ? touched && selectedField == null : false}
|
||||
onFocus={handleTouch}
|
||||
singleSelection={AS_PLAIN_TEXT}
|
||||
data-test-subj="fieldAutocompleteComboBox"
|
||||
style={fieldWidth}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FieldComponent.displayName = 'Field';
|
||||
|
||||
interface ComboBoxFields {
|
||||
availableFields: IFieldType[];
|
||||
selectedFields: IFieldType[];
|
||||
}
|
||||
|
||||
const getComboBoxFields = (
|
||||
indexPattern: IIndexPattern | undefined,
|
||||
selectedField: IFieldType | undefined,
|
||||
fieldTypeFilter: string[]
|
||||
): ComboBoxFields => {
|
||||
const existingFields = getExistingFields(indexPattern);
|
||||
const selectedFields = getSelectedFields(selectedField);
|
||||
const availableFields = getAvailableFields(existingFields, selectedFields, fieldTypeFilter);
|
||||
|
||||
return { availableFields, selectedFields };
|
||||
};
|
||||
|
||||
const getComboBoxProps = (fields: ComboBoxFields): GetGenericComboBoxPropsReturn => {
|
||||
const { availableFields, selectedFields } = fields;
|
||||
|
||||
return getGenericComboBoxProps<IFieldType>({
|
||||
getLabel: (field) => field.name,
|
||||
options: availableFields,
|
||||
selectedOptions: selectedFields,
|
||||
});
|
||||
};
|
||||
|
||||
const getExistingFields = (indexPattern: IIndexPattern | undefined): IFieldType[] => {
|
||||
return indexPattern != null ? indexPattern.fields : [];
|
||||
};
|
||||
|
||||
const getSelectedFields = (selectedField: IFieldType | undefined): IFieldType[] => {
|
||||
return selectedField ? [selectedField] : [];
|
||||
};
|
||||
|
||||
const getAvailableFields = (
|
||||
existingFields: IFieldType[],
|
||||
selectedFields: IFieldType[],
|
||||
fieldTypeFilter: string[]
|
||||
): IFieldType[] => {
|
||||
const fieldsByName = new Map<string, IFieldType>();
|
||||
|
||||
existingFields.forEach((f) => fieldsByName.set(f.name, f));
|
||||
selectedFields.forEach((f) => fieldsByName.set(f.name, f));
|
||||
|
||||
const uniqueFields = Array.from(fieldsByName.values());
|
||||
|
||||
if (fieldTypeFilter.length > 0) {
|
||||
return uniqueFields.filter(({ type }) => fieldTypeFilter.includes(type));
|
||||
}
|
||||
|
||||
return uniqueFields;
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { AutocompleteFieldExistsComponent } from './field_value_exists';
|
||||
|
||||
describe('AutocompleteFieldExistsComponent', () => {
|
||||
test('it renders field disabled', () => {
|
||||
const wrapper = mount(<AutocompleteFieldExistsComponent placeholder="Placeholder text" />);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox existsComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
|
||||
const NO_OPTIONS_FOR_EXIST: EuiComboBoxOptionOption[] = [];
|
||||
|
||||
interface AutocompleteFieldExistsProps {
|
||||
placeholder: string;
|
||||
rowLabel?: string;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldExistsComponent: React.FC<AutocompleteFieldExistsProps> = ({
|
||||
placeholder,
|
||||
rowLabel,
|
||||
}): JSX.Element => (
|
||||
<EuiFormRow label={rowLabel} fullWidth>
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={NO_OPTIONS_FOR_EXIST}
|
||||
selectedOptions={NO_OPTIONS_FOR_EXIST}
|
||||
onChange={undefined}
|
||||
isDisabled
|
||||
data-test-subj="valuesAutocompleteComboBox existsComboxBox"
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
|
||||
AutocompleteFieldExistsComponent.displayName = 'AutocompleteFieldExists';
|
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { ListSchema } from '../../../../common';
|
||||
import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock';
|
||||
import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
|
||||
import { DATE_NOW, IMMUTABLE, VERSION } from '../../../../../lists/common/constants.mock';
|
||||
|
||||
import { AutocompleteFieldListsComponent } from './field_value_lists';
|
||||
|
||||
const mockKibanaHttpService = coreMock.createStart().http;
|
||||
|
||||
const mockStart = jest.fn();
|
||||
const mockKeywordList: ListSchema = {
|
||||
...getListResponseMock(),
|
||||
id: 'keyword_list',
|
||||
name: 'keyword list',
|
||||
type: 'keyword',
|
||||
};
|
||||
const mockResult = { ...getFoundListSchemaMock() };
|
||||
mockResult.data = [...mockResult.data, mockKeywordList];
|
||||
jest.mock('../../..', () => {
|
||||
const originalModule = jest.requireActual('../../..');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
useFindLists: () => ({
|
||||
error: undefined,
|
||||
loading: false,
|
||||
result: mockResult,
|
||||
start: mockStart.mockReturnValue(mockResult),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AutocompleteFieldListsComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', async () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={true}
|
||||
isDisabled
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', async () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('@tags')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
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', async () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]')
|
||||
.prop('options')
|
||||
).toEqual([{ label: 'some name' }]);
|
||||
});
|
||||
|
||||
test('it correctly displays lists that match the selected "keyword" field esType', () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('@tags')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click');
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]')
|
||||
.prop('options')
|
||||
).toEqual([{ label: 'keyword list' }]);
|
||||
});
|
||||
|
||||
test('it correctly displays lists that match the selected "ip" field esType', () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="comboBoxToggleListButton"] button').simulate('click');
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]')
|
||||
.prop('options')
|
||||
).toEqual([{ label: 'some name' }]);
|
||||
});
|
||||
|
||||
test('it correctly displays selected list', async () => {
|
||||
const wrapper = mount(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={mockKibanaHttpService}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'some name' }]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
_version: undefined,
|
||||
created_at: DATE_NOW,
|
||||
created_by: 'some user',
|
||||
description: 'some description',
|
||||
deserializer: undefined,
|
||||
id: 'some-list-id',
|
||||
immutable: IMMUTABLE,
|
||||
meta: {},
|
||||
name: 'some name',
|
||||
serializer: undefined,
|
||||
tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
|
||||
type: 'ip',
|
||||
updated_at: DATE_NOW,
|
||||
updated_by: 'some user',
|
||||
version: VERSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
import { HttpStart } from 'kibana/public';
|
||||
|
||||
import { ListSchema } from '../../../../common';
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { useFindLists } from '../../..';
|
||||
|
||||
import { filterFieldToList, getGenericComboBoxProps } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const SINGLE_SELECTION = { asPlainText: true };
|
||||
|
||||
interface AutocompleteFieldListsProps {
|
||||
httpService: HttpStart;
|
||||
isClearable: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
onChange: (arg: ListSchema) => void;
|
||||
placeholder: string;
|
||||
rowLabel?: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
selectedValue: string | undefined;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldListsComponent: React.FC<AutocompleteFieldListsProps> = ({
|
||||
httpService,
|
||||
isClearable = false,
|
||||
isDisabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
placeholder,
|
||||
rowLabel,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
}): JSX.Element => {
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [lists, setLists] = useState<ListSchema[]>([]);
|
||||
const { loading, result, start } = useFindLists();
|
||||
const getLabel = useCallback(({ name }) => name, []);
|
||||
|
||||
const optionsMemo = useMemo(() => filterFieldToList(lists, selectedField), [
|
||||
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<ListSchema>({
|
||||
getLabel,
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]) => {
|
||||
const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
onChange(newValue ?? '');
|
||||
},
|
||||
[labels, optionsMemo, onChange]
|
||||
);
|
||||
|
||||
const setIsTouchedValue = useCallback((): void => {
|
||||
setError(selectedValue == null ? i18n.FIELD_REQUIRED_ERR : undefined);
|
||||
}, [selectedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result != null) {
|
||||
setLists(result.data);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField != null && httpService != null) {
|
||||
start({
|
||||
http: httpService,
|
||||
pageIndex: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
}
|
||||
}, [selectedField, start, httpService]);
|
||||
|
||||
const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]);
|
||||
|
||||
return (
|
||||
<EuiFormRow label={rowLabel} error={error} isInvalid={error != null} fullWidth>
|
||||
<EuiComboBox
|
||||
async
|
||||
data-test-subj="valuesAutocompleteComboBox listsComboxBox"
|
||||
fullWidth
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
isInvalid={error != null}
|
||||
isLoading={isLoadingState}
|
||||
onBlur={setIsTouchedValue}
|
||||
onChange={handleValuesChange}
|
||||
options={comboOptions}
|
||||
placeholder={placeholder}
|
||||
selectedOptions={selectedComboOptions}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
sortMatchesBy="startsWith"
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
||||
|
||||
AutocompleteFieldListsComponent.displayName = 'AutocompleteFieldList';
|
|
@ -0,0 +1,443 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ReactWrapper, mount } from 'enzyme';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiSuperSelect } from '@elastic/eui';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
|
||||
import { AutocompleteFieldMatchComponent } from './field_value_match';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
|
||||
jest.mock('./hooks/use_field_value_autocomplete');
|
||||
|
||||
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
|
||||
|
||||
describe('AutocompleteFieldMatchComponent', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
const getValueSuggestionsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]);
|
||||
|
||||
beforeEach(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
true,
|
||||
['value 1', 'value 2'],
|
||||
getValueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('it renders row label if one passed in', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatchLabel"] label').at(0).text()
|
||||
).toEqual('Row Label');
|
||||
});
|
||||
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatch"] input').prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
/>
|
||||
);
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click');
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatch-optionsList"]')
|
||||
.prop('isLoading')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it allows user to clear values if "isClearable" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[data-test-subj="comboBoxInput"]')
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected value', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatch"] EuiComboBoxPill').at(0).text()
|
||||
).toEqual('126.45.211.34');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value created', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
((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();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'value 1' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('value 1');
|
||||
});
|
||||
|
||||
test('it refreshes autocomplete with search query when new value searched', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onSearchChange: (a: string) => void;
|
||||
}).onSearchChange('value 1');
|
||||
});
|
||||
|
||||
expect(useFieldValueAutocomplete).toHaveBeenCalledWith({
|
||||
autocompleteService: autocompleteStartMock,
|
||||
fieldValue: '',
|
||||
indexPattern: {
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
},
|
||||
operatorType: 'match',
|
||||
query: 'value 1',
|
||||
selectedField: getField('machine.os.raw'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean type', () => {
|
||||
const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]);
|
||||
|
||||
beforeEach(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
false,
|
||||
[],
|
||||
valueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
|
||||
test('it displays only two options - "true" or "false"', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ssl')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').at(0).prop('options')
|
||||
).toEqual([
|
||||
{
|
||||
inputDisplay: 'true',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
inputDisplay: 'false',
|
||||
value: 'false',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it invokes "onChange" with "true" when selected', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ssl')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiSuperSelect).props() as unknown) as {
|
||||
onChange: (a: string) => void;
|
||||
}).onChange('true');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('true');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" with "false" when selected', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ssl')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiSuperSelect).props() as unknown) as {
|
||||
onChange: (a: string) => void;
|
||||
}).onChange('false');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('number type', () => {
|
||||
const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]);
|
||||
|
||||
beforeEach(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
false,
|
||||
[],
|
||||
valueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
|
||||
test('it number input when field type is number', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('bytes')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="valueAutocompleteFieldMatchNumber"]').exists()
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it invokes "onChange" with numeric value when inputted', () => {
|
||||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteStartMock}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('bytes')}
|
||||
selectedValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper
|
||||
.find('[data-test-subj="valueAutocompleteFieldMatchNumber"] input')
|
||||
.at(0)
|
||||
.simulate('change', { target: { value: '8' } });
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('8');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiComboBox,
|
||||
EuiComboBoxOptionOption,
|
||||
EuiFieldNumber,
|
||||
EuiFormRow,
|
||||
EuiSuperSelect,
|
||||
} from '@elastic/eui';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { OperatorTypeEnum } from '../../../../common';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { AutocompleteStart } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
import { getGenericComboBoxProps, paramIsValid } from './helpers';
|
||||
import { GetGenericComboBoxPropsReturn } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const BOOLEAN_OPTIONS = [
|
||||
{ inputDisplay: 'true', value: 'true' },
|
||||
{ inputDisplay: 'false', value: 'false' },
|
||||
];
|
||||
|
||||
const SINGLE_SELECTION = { asPlainText: true };
|
||||
|
||||
interface AutocompleteFieldMatchProps {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
selectedValue: string | undefined;
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
isRequired?: boolean;
|
||||
fieldInputWidth?: number;
|
||||
rowLabel?: string;
|
||||
autocompleteService: AutocompleteStart;
|
||||
onChange: (arg: string) => void;
|
||||
onError?: (arg: boolean) => void;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldMatchComponent: React.FC<AutocompleteFieldMatchProps> = ({
|
||||
placeholder,
|
||||
rowLabel,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
isRequired = false,
|
||||
fieldInputWidth,
|
||||
onChange,
|
||||
onError,
|
||||
autocompleteService,
|
||||
}): JSX.Element => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [touched, setIsTouched] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({
|
||||
autocompleteService,
|
||||
fieldValue: selectedValue,
|
||||
indexPattern,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: searchQuery,
|
||||
selectedField,
|
||||
});
|
||||
const getLabel = useCallback((option: string): string => option, []);
|
||||
const optionsMemo = useMemo((): string[] => {
|
||||
const valueAsStr = String(selectedValue);
|
||||
return selectedValue != null && selectedValue.trim() !== ''
|
||||
? uniq([valueAsStr, ...suggestions])
|
||||
: suggestions;
|
||||
}, [suggestions, selectedValue]);
|
||||
const selectedOptionsMemo = useMemo((): string[] => {
|
||||
const valueAsStr = String(selectedValue);
|
||||
return selectedValue ? [valueAsStr] : [];
|
||||
}, [selectedValue]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: string | undefined): void => {
|
||||
setError((existingErr): string | undefined => {
|
||||
const oldErr = existingErr != null;
|
||||
const newErr = err != null;
|
||||
if (oldErr !== newErr && onError != null) {
|
||||
onError(newErr);
|
||||
}
|
||||
|
||||
return err;
|
||||
});
|
||||
},
|
||||
[setError, onError]
|
||||
);
|
||||
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<string>({
|
||||
getLabel,
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
handleError(undefined);
|
||||
onChange(newValue ?? '');
|
||||
},
|
||||
[handleError, labels, onChange, optionsMemo]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(searchVal: string): void => {
|
||||
if (searchVal !== '' && selectedField != null) {
|
||||
const err = paramIsValid(searchVal, selectedField, isRequired, touched);
|
||||
handleError(err);
|
||||
|
||||
setSearchQuery(searchVal);
|
||||
}
|
||||
},
|
||||
[handleError, isRequired, selectedField, touched]
|
||||
);
|
||||
|
||||
const handleCreateOption = useCallback(
|
||||
(option: string): boolean | undefined => {
|
||||
const err = paramIsValid(option, selectedField, isRequired, touched);
|
||||
handleError(err);
|
||||
|
||||
if (err != null) {
|
||||
// Explicitly reject the user's input
|
||||
return false;
|
||||
} else {
|
||||
onChange(option);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[isRequired, onChange, selectedField, touched, handleError]
|
||||
);
|
||||
|
||||
const handleNonComboBoxInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const newValue = event.target.value;
|
||||
onChange(newValue);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleBooleanInputChange = useCallback(
|
||||
(newOption: string): void => {
|
||||
onChange(newOption);
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const setIsTouchedValue = useCallback((): void => {
|
||||
setIsTouched(true);
|
||||
|
||||
const err = paramIsValid(selectedValue, selectedField, isRequired, true);
|
||||
handleError(err);
|
||||
}, [setIsTouched, handleError, selectedValue, selectedField, isRequired]);
|
||||
|
||||
const inputPlaceholder = useMemo((): string => {
|
||||
if (isLoading || isLoadingSuggestions) {
|
||||
return i18n.LOADING;
|
||||
} else if (selectedField == null) {
|
||||
return i18n.SELECT_FIELD_FIRST;
|
||||
} else {
|
||||
return placeholder;
|
||||
}
|
||||
}, [isLoading, selectedField, isLoadingSuggestions, placeholder]);
|
||||
|
||||
const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [
|
||||
isLoading,
|
||||
isLoadingSuggestions,
|
||||
]);
|
||||
|
||||
const fieldInputWidths = useMemo(
|
||||
() => (fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}),
|
||||
[fieldInputWidth]
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
setError(undefined);
|
||||
if (onError != null) {
|
||||
onError(false);
|
||||
}
|
||||
}, [selectedField, onError]);
|
||||
|
||||
const defaultInput = useMemo((): JSX.Element => {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
error={error}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
data-test-subj="valuesAutocompleteMatchLabel"
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
placeholder={inputPlaceholder}
|
||||
isDisabled={isDisabled || !selectedField}
|
||||
isLoading={isLoadingState}
|
||||
isClearable={isClearable}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
singleSelection={SINGLE_SELECTION}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreateOption={handleCreateOption}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
onBlur={setIsTouchedValue}
|
||||
sortMatchesBy="startsWith"
|
||||
data-test-subj="valuesAutocompleteMatch"
|
||||
style={fieldInputWidths}
|
||||
fullWidth
|
||||
async
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}, [
|
||||
comboOptions,
|
||||
error,
|
||||
fieldInputWidths,
|
||||
handleCreateOption,
|
||||
handleSearchChange,
|
||||
handleValuesChange,
|
||||
inputPlaceholder,
|
||||
isClearable,
|
||||
isDisabled,
|
||||
isLoadingState,
|
||||
rowLabel,
|
||||
selectedComboOptions,
|
||||
selectedField,
|
||||
setIsTouchedValue,
|
||||
]);
|
||||
|
||||
if (!isSuggestingValues && selectedField != null) {
|
||||
switch (selectedField.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
error={error}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
data-test-subj="valuesAutocompleteMatchLabel"
|
||||
fullWidth
|
||||
>
|
||||
<EuiFieldNumber
|
||||
placeholder={inputPlaceholder}
|
||||
onBlur={setIsTouchedValue}
|
||||
value={
|
||||
typeof selectedValue === 'string' && selectedValue.trim().length > 0
|
||||
? parseFloat(selectedValue)
|
||||
: selectedValue ?? ''
|
||||
}
|
||||
onChange={handleNonComboBoxInputChange}
|
||||
data-test-subj="valueAutocompleteFieldMatchNumber"
|
||||
style={fieldInputWidths}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
error={error}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
data-test-subj="valuesAutocompleteMatchLabel"
|
||||
fullWidth
|
||||
>
|
||||
<EuiSuperSelect
|
||||
isLoading={isLoadingState}
|
||||
options={BOOLEAN_OPTIONS}
|
||||
valueOfSelected={selectedValue ?? 'true'}
|
||||
onChange={handleBooleanInputChange}
|
||||
data-test-subj="valuesAutocompleteMatchBoolean"
|
||||
style={fieldInputWidths}
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
default:
|
||||
return defaultInput;
|
||||
}
|
||||
} else {
|
||||
return defaultInput;
|
||||
}
|
||||
};
|
||||
|
||||
AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch';
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ReactWrapper, mount } from 'enzyme';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
|
||||
|
||||
import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
|
||||
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
|
||||
|
||||
jest.mock('./hooks/use_field_value_autocomplete');
|
||||
|
||||
describe('AutocompleteFieldMatchAnyComponent', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
const getValueSuggestionsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]);
|
||||
|
||||
beforeEach(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
true,
|
||||
['value 1', 'value 2'],
|
||||
getValueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] input`).prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={true}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={[]}
|
||||
/>
|
||||
);
|
||||
wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] button`).at(0).simulate('click');
|
||||
expect(
|
||||
wrapper
|
||||
.find(`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatchAny-optionsList"]`)
|
||||
.prop('isLoading')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it allows user to clear values if "isClearable" is true', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected value', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="valuesAutocompleteMatchAny"] EuiComboBoxPill`).at(0).text()
|
||||
).toEqual('126.45.211.34');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value created', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
((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();
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
onError={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'value 1' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['value 1']);
|
||||
});
|
||||
|
||||
test('it refreshes autocomplete with search query when new value searched', () => {
|
||||
wrapper = mount(
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={{
|
||||
...autocompleteStartMock,
|
||||
}}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
}}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
placeholder="Placeholder text"
|
||||
rowLabel={'Row Label'}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue={[]}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onSearchChange: (a: string) => void;
|
||||
}).onSearchChange('value 1');
|
||||
});
|
||||
expect(useFieldValueAutocomplete).toHaveBeenCalledWith({
|
||||
autocompleteService: autocompleteStartMock,
|
||||
fieldValue: [],
|
||||
indexPattern: {
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
},
|
||||
operatorType: 'match_any',
|
||||
query: 'value 1',
|
||||
selectedField: getField('machine.os.raw'),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { OperatorTypeEnum } from '../../../../common';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { AutocompleteStart } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
import { getGenericComboBoxProps, paramIsValid } from './helpers';
|
||||
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;
|
||||
isRequired?: boolean;
|
||||
rowLabel?: string;
|
||||
autocompleteService: AutocompleteStart;
|
||||
onChange: (arg: string[]) => void;
|
||||
onError?: (arg: boolean) => void;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldMatchAnyComponent: React.FC<AutocompleteFieldMatchAnyProps> = ({
|
||||
placeholder,
|
||||
rowLabel,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
isRequired = false,
|
||||
onChange,
|
||||
onError,
|
||||
autocompleteService,
|
||||
}): JSX.Element => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [touched, setIsTouched] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({
|
||||
autocompleteService,
|
||||
fieldValue: selectedValue,
|
||||
indexPattern,
|
||||
operatorType: OperatorTypeEnum.MATCH_ANY,
|
||||
query: searchQuery,
|
||||
selectedField,
|
||||
});
|
||||
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<string>({
|
||||
getLabel,
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedValue,
|
||||
}),
|
||||
[optionsMemo, selectedValue, getLabel]
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: string | undefined): void => {
|
||||
setError((existingErr): string | undefined => {
|
||||
const oldErr = existingErr != null;
|
||||
const newErr = err != null;
|
||||
if (oldErr !== newErr && onError != null) {
|
||||
onError(newErr);
|
||||
}
|
||||
|
||||
return err;
|
||||
});
|
||||
},
|
||||
[setError, onError]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: string[] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
handleError(undefined);
|
||||
onChange(newValues);
|
||||
},
|
||||
[handleError, labels, onChange, optionsMemo]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(searchVal: string) => {
|
||||
if (searchVal === '') {
|
||||
handleError(undefined);
|
||||
}
|
||||
|
||||
if (searchVal !== '' && selectedField != null) {
|
||||
const err = paramIsValid(searchVal, selectedField, isRequired, touched);
|
||||
handleError(err);
|
||||
|
||||
setSearchQuery(searchVal);
|
||||
}
|
||||
},
|
||||
[handleError, isRequired, selectedField, touched]
|
||||
);
|
||||
|
||||
const handleCreateOption = useCallback(
|
||||
(option: string): boolean => {
|
||||
const err = paramIsValid(option, selectedField, isRequired, touched);
|
||||
handleError(err);
|
||||
|
||||
if (err != null) {
|
||||
// Explicitly reject the user's input
|
||||
return false;
|
||||
} else {
|
||||
onChange([...(selectedValue || []), option]);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
[handleError, isRequired, onChange, selectedField, selectedValue, touched]
|
||||
);
|
||||
|
||||
const setIsTouchedValue = useCallback((): void => {
|
||||
handleError(selectedComboOptions.length === 0 ? i18n.FIELD_REQUIRED_ERR : undefined);
|
||||
setIsTouched(true);
|
||||
}, [setIsTouched, handleError, selectedComboOptions]);
|
||||
|
||||
const inputPlaceholder = useMemo(
|
||||
(): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder),
|
||||
[isLoading, isLoadingSuggestions, placeholder]
|
||||
);
|
||||
|
||||
const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [
|
||||
isLoading,
|
||||
isLoadingSuggestions,
|
||||
]);
|
||||
|
||||
const defaultInput = useMemo((): JSX.Element => {
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
error={error}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
placeholder={inputPlaceholder}
|
||||
isLoading={isLoadingState}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreateOption={handleCreateOption}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
onBlur={setIsTouchedValue}
|
||||
delimiter=", "
|
||||
data-test-subj="valuesAutocompleteMatchAny"
|
||||
fullWidth
|
||||
async
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
}, [
|
||||
comboOptions,
|
||||
error,
|
||||
handleCreateOption,
|
||||
handleSearchChange,
|
||||
handleValuesChange,
|
||||
inputPlaceholder,
|
||||
isClearable,
|
||||
isDisabled,
|
||||
isLoadingState,
|
||||
rowLabel,
|
||||
selectedComboOptions,
|
||||
selectedField,
|
||||
setIsTouchedValue,
|
||||
]);
|
||||
|
||||
if (!isSuggestingValues && selectedField != null) {
|
||||
switch (selectedField.type) {
|
||||
case 'number':
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={rowLabel}
|
||||
error={error}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
fullWidth
|
||||
>
|
||||
<EuiComboBox
|
||||
noSuggestions
|
||||
placeholder={inputPlaceholder}
|
||||
isLoading={isLoadingState}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onCreateOption={handleCreateOption}
|
||||
isInvalid={selectedField != null && error != null}
|
||||
onFocus={setIsTouchedValue}
|
||||
delimiter=", "
|
||||
data-test-subj="valuesAutocompleteMatchAnyNumber"
|
||||
fullWidth
|
||||
/>
|
||||
</EuiFormRow>
|
||||
);
|
||||
default:
|
||||
return defaultInput;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultInput;
|
||||
};
|
||||
|
||||
AutocompleteFieldMatchAnyComponent.displayName = 'AutocompleteFieldMatchAny';
|
|
@ -0,0 +1,388 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock';
|
||||
import { ListSchema } from '../../../../common';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
doesNotExistOperator,
|
||||
existsOperator,
|
||||
isNotOperator,
|
||||
isOperator,
|
||||
} from './operators';
|
||||
import {
|
||||
checkEmptyValue,
|
||||
filterFieldToList,
|
||||
getGenericComboBoxProps,
|
||||
getOperators,
|
||||
paramIsValid,
|
||||
typeMatch,
|
||||
} from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
// @ts-ignore
|
||||
moment.suppressDeprecationWarnings = true;
|
||||
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({
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['text'],
|
||||
name: 'nestedField',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
subType: { nested: { path: 'nestedField' } },
|
||||
type: 'nested',
|
||||
});
|
||||
|
||||
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('#checkEmptyValue', () => {
|
||||
test('returns no errors if no field has been selected', () => {
|
||||
const isValid = checkEmptyValue('', undefined, true, false);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns error string if user has touched a required input and left empty', () => {
|
||||
const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true);
|
||||
|
||||
expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR);
|
||||
});
|
||||
|
||||
test('returns no errors if required input is empty but user has not yet touched it', () => {
|
||||
const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if user has touched an input that is not required and left empty', () => {
|
||||
const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if user has touched an input that is not required and left empty string', () => {
|
||||
const isValid = checkEmptyValue('', getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns null if input value is not empty string or undefined', () => {
|
||||
const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#paramIsValid', () => {
|
||||
test('returns no errors if no field has been selected', () => {
|
||||
const isValid = paramIsValid('', undefined, true, false);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns error string if user has touched a required input and left empty', () => {
|
||||
const isValid = paramIsValid(undefined, getField('@timestamp'), true, true);
|
||||
|
||||
expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR);
|
||||
});
|
||||
|
||||
test('returns no errors if required input is empty but user has not yet touched it', () => {
|
||||
const isValid = paramIsValid(undefined, getField('@timestamp'), true, false);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if user has touched an input that is not required and left empty', () => {
|
||||
const isValid = paramIsValid(undefined, getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if user has touched an input that is not required and left empty string', () => {
|
||||
const isValid = paramIsValid('', getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if field is of type date and value is valid', () => {
|
||||
const isValid = paramIsValid(
|
||||
'1994-11-05T08:15:30-05:00',
|
||||
getField('@timestamp'),
|
||||
false,
|
||||
true
|
||||
);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns errors if filed is of type date and value is not valid', () => {
|
||||
const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true);
|
||||
|
||||
expect(isValid).toEqual(i18n.DATE_ERR);
|
||||
});
|
||||
|
||||
test('returns no errors if field is of type number and value is an integer', () => {
|
||||
const isValid = paramIsValid('4', getField('bytes'), true, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if field is of type number and value is a float', () => {
|
||||
const isValid = paramIsValid('4.3', getField('bytes'), true, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns no errors if field is of type number and value is a long', () => {
|
||||
const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true);
|
||||
|
||||
expect(isValid).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns errors if field is of type number and value is "hello"', () => {
|
||||
const isValid = paramIsValid('hello', getField('bytes'), true, true);
|
||||
|
||||
expect(isValid).toEqual(i18n.NUMBER_ERR);
|
||||
});
|
||||
|
||||
test('returns errors if field is of type number and value is "123abc"', () => {
|
||||
const isValid = paramIsValid('123abc', getField('bytes'), true, true);
|
||||
|
||||
expect(isValid).toEqual(i18n.NUMBER_ERR);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getGenericComboBoxProps', () => {
|
||||
test('it returns empty arrays if "options" is empty array', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
getLabel: (t: string) => t,
|
||||
options: [],
|
||||
selectedOptions: ['option1'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] });
|
||||
});
|
||||
|
||||
test('it returns formatted props if "options" array is not empty', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
getLabel: (t: string) => t,
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: [],
|
||||
});
|
||||
|
||||
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<string>({
|
||||
getLabel: (t: string) => t,
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: ['option4'],
|
||||
});
|
||||
|
||||
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<string>({
|
||||
getLabel: (t: string) => t,
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: ['option2'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
comboOptions: [
|
||||
{
|
||||
label: 'option1',
|
||||
},
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
{
|
||||
label: 'option3',
|
||||
},
|
||||
],
|
||||
labels: ['option1', 'option2', 'option3'],
|
||||
selectedComboOptions: [
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#typeMatch', () => {
|
||||
test('ip -> ip is true', () => {
|
||||
expect(typeMatch('ip', 'ip')).toEqual(true);
|
||||
});
|
||||
|
||||
test('keyword -> keyword is true', () => {
|
||||
expect(typeMatch('keyword', 'keyword')).toEqual(true);
|
||||
});
|
||||
|
||||
test('text -> text is true', () => {
|
||||
expect(typeMatch('text', 'text')).toEqual(true);
|
||||
});
|
||||
|
||||
test('ip_range -> ip is true', () => {
|
||||
expect(typeMatch('ip_range', 'ip')).toEqual(true);
|
||||
});
|
||||
|
||||
test('date_range -> date is true', () => {
|
||||
expect(typeMatch('date_range', 'date')).toEqual(true);
|
||||
});
|
||||
|
||||
test('double_range -> double is true', () => {
|
||||
expect(typeMatch('double_range', 'double')).toEqual(true);
|
||||
});
|
||||
|
||||
test('float_range -> float is true', () => {
|
||||
expect(typeMatch('float_range', 'float')).toEqual(true);
|
||||
});
|
||||
|
||||
test('integer_range -> integer is true', () => {
|
||||
expect(typeMatch('integer_range', 'integer')).toEqual(true);
|
||||
});
|
||||
|
||||
test('long_range -> long is true', () => {
|
||||
expect(typeMatch('long_range', 'long')).toEqual(true);
|
||||
});
|
||||
|
||||
test('ip -> date is false', () => {
|
||||
expect(typeMatch('ip', 'date')).toEqual(false);
|
||||
});
|
||||
|
||||
test('long -> float is false', () => {
|
||||
expect(typeMatch('long', 'float')).toEqual(false);
|
||||
});
|
||||
|
||||
test('integer -> long is false', () => {
|
||||
expect(typeMatch('integer', 'long')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filterFieldToList', () => {
|
||||
test('it returns empty array if given a undefined for field', () => {
|
||||
const filter = filterFieldToList([], undefined);
|
||||
expect(filter).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns empty array if filed does not contain esTypes', () => {
|
||||
const field: IFieldType = { name: 'some-name', type: 'some-type' };
|
||||
const filter = filterFieldToList([], field);
|
||||
expect(filter).toEqual([]);
|
||||
});
|
||||
|
||||
test('it returns single filtered list of ip_range -> ip', () => {
|
||||
const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' };
|
||||
const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' };
|
||||
const filter = filterFieldToList([listItem], field);
|
||||
const expected: ListSchema[] = [listItem];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns single filtered list of ip -> ip', () => {
|
||||
const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' };
|
||||
const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' };
|
||||
const filter = filterFieldToList([listItem], field);
|
||||
const expected: ListSchema[] = [listItem];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns single filtered list of keyword -> keyword', () => {
|
||||
const field: IFieldType = { esTypes: ['keyword'], name: 'some-name', type: 'keyword' };
|
||||
const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' };
|
||||
const filter = filterFieldToList([listItem], field);
|
||||
const expected: ListSchema[] = [listItem];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns single filtered list of text -> text', () => {
|
||||
const field: IFieldType = { esTypes: ['text'], name: 'some-name', type: 'text' };
|
||||
const listItem: ListSchema = { ...getListResponseMock(), type: 'text' };
|
||||
const filter = filterFieldToList([listItem], field);
|
||||
const expected: ListSchema[] = [listItem];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns 2 filtered lists of ip_range -> ip', () => {
|
||||
const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' };
|
||||
const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' };
|
||||
const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' };
|
||||
const filter = filterFieldToList([listItem1, listItem2], field);
|
||||
const expected: ListSchema[] = [listItem1, listItem2];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => {
|
||||
const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' };
|
||||
const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' };
|
||||
const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' };
|
||||
const filter = filterFieldToList([listItem1, listItem2], field);
|
||||
const expected: ListSchema[] = [listItem1];
|
||||
expect(filter).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import dateMath from '@elastic/datemath';
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { ListSchema, Type } from '../../../../common';
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
doesNotExistOperator,
|
||||
existsOperator,
|
||||
isNotOperator,
|
||||
isOperator,
|
||||
} from './operators';
|
||||
import { GetGenericComboBoxPropsReturn, OperatorOption } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
/**
|
||||
* Returns the appropriate operators given a field type
|
||||
*
|
||||
* @param field IFieldType selected field
|
||||
*
|
||||
*/
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if empty value is ok
|
||||
*
|
||||
* @param param the value being checked
|
||||
* @param field the selected field
|
||||
* @param isRequired whether or not an empty value is allowed
|
||||
* @param touched has field been touched by user
|
||||
* @returns undefined if valid, string with error message if invalid,
|
||||
* null if no checks matched
|
||||
*/
|
||||
export const checkEmptyValue = (
|
||||
param: string | undefined,
|
||||
field: IFieldType | undefined,
|
||||
isRequired: boolean,
|
||||
touched: boolean
|
||||
): string | undefined | null => {
|
||||
if (isRequired && touched && (param == null || param.trim() === '')) {
|
||||
return i18n.FIELD_REQUIRED_ERR;
|
||||
}
|
||||
|
||||
if (
|
||||
field == null ||
|
||||
(isRequired && !touched) ||
|
||||
(!isRequired && (param == null || param === ''))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Very basic validation for values
|
||||
*
|
||||
* @param param the value being checked
|
||||
* @param field the selected field
|
||||
* @param isRequired whether or not an empty value is allowed
|
||||
* @param touched has field been touched by user
|
||||
* @returns undefined if valid, string with error message if invalid
|
||||
*/
|
||||
export const paramIsValid = (
|
||||
param: string | undefined,
|
||||
field: IFieldType | undefined,
|
||||
isRequired: boolean,
|
||||
touched: boolean
|
||||
): string | undefined => {
|
||||
if (field == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emptyValueError = checkEmptyValue(param, field, isRequired, touched);
|
||||
if (emptyValueError !== null) {
|
||||
return emptyValueError;
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case 'date':
|
||||
const moment = dateMath.parse(param ?? '');
|
||||
const isDate = Boolean(moment && moment.isValid());
|
||||
return isDate ? undefined : i18n.DATE_ERR;
|
||||
case 'number':
|
||||
const isNum = param != null && param.trim() !== '' && !isNaN(+param);
|
||||
return isNum ? undefined : i18n.NUMBER_ERR;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the options, selected values and option labels for EUI combo box
|
||||
*
|
||||
* @param options options user can select from
|
||||
* @param selectedOptions user selection if any
|
||||
* @param getLabel helper function to know which property to use for labels
|
||||
*/
|
||||
export const getGenericComboBoxProps = <T>({
|
||||
getLabel,
|
||||
options,
|
||||
selectedOptions,
|
||||
}: {
|
||||
getLabel: (value: T) => string;
|
||||
options: T[];
|
||||
selectedOptions: T[];
|
||||
}): GetGenericComboBoxPropsReturn => {
|
||||
const newLabels = options.map(getLabel);
|
||||
const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label }));
|
||||
const newSelectedComboOptions = selectedOptions
|
||||
.map(getLabel)
|
||||
.filter((option) => {
|
||||
return newLabels.indexOf(option) !== -1;
|
||||
})
|
||||
.map((option) => {
|
||||
return newComboOptions[newLabels.indexOf(option)];
|
||||
});
|
||||
|
||||
return {
|
||||
comboOptions: newComboOptions,
|
||||
labels: newLabels,
|
||||
selectedComboOptions: newSelectedComboOptions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an array of lists and optionally a field this will return all
|
||||
* the lists that match against the field based on the types from the field
|
||||
* @param lists The lists to match against the field
|
||||
* @param field The field to check against the list to see if they are compatible
|
||||
*/
|
||||
export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => {
|
||||
if (field != null) {
|
||||
const { esTypes = [] } = field;
|
||||
return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType)));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an input list type and a string based ES type this will match
|
||||
* if they're exact or if they are compatible with a range
|
||||
* @param type The type to match against the esType
|
||||
* @param esType The ES type to match with
|
||||
*/
|
||||
export const typeMatch = (type: Type, esType: string): boolean => {
|
||||
return (
|
||||
type === esType ||
|
||||
(type === 'ip_range' && esType === 'ip') ||
|
||||
(type === 'date_range' && esType === 'date') ||
|
||||
(type === 'double_range' && esType === 'double') ||
|
||||
(type === 'float_range' && esType === 'float') ||
|
||||
(type === 'integer_range' && esType === 'integer') ||
|
||||
(type === 'long_range' && esType === 'long')
|
||||
);
|
||||
};
|
|
@ -0,0 +1,334 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub';
|
||||
import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { OperatorTypeEnum } from '../../../../../common';
|
||||
import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks';
|
||||
|
||||
import {
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn,
|
||||
useFieldValueAutocomplete,
|
||||
} from './use_field_value_autocomplete';
|
||||
|
||||
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
|
||||
|
||||
jest.mock('../../../../../../../../src/plugins/kibana_react/public');
|
||||
|
||||
describe('useFieldValueAutocomplete', () => {
|
||||
const onErrorMock = jest.fn();
|
||||
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']);
|
||||
|
||||
afterEach(() => {
|
||||
onErrorMock.mockClear();
|
||||
getValueSuggestionsMock.mockClear();
|
||||
});
|
||||
|
||||
test('initializes hook', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: undefined,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: undefined,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current).toEqual([false, true, [], result.current[3]]);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call autocomplete service if "operatorType" is "exists"', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
query: '',
|
||||
selectedField: getField('machine.os'),
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]];
|
||||
|
||||
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({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
query: '',
|
||||
selectedField: undefined,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]];
|
||||
|
||||
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({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: undefined,
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
query: '',
|
||||
selectedField: getField('machine.os'),
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('it uses full path name for nested fields to fetch suggestions', async () => {
|
||||
const suggestionsMock = jest.fn().mockResolvedValue([]);
|
||||
|
||||
await act(async () => {
|
||||
const { signal } = new AbortController();
|
||||
const { waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: suggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: { ...getField('nestedField.child'), name: 'child' },
|
||||
})
|
||||
);
|
||||
// Note: initial `waitForNextUpdate` is hook initialization
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(suggestionsMock).toHaveBeenCalledWith({
|
||||
field: { ...getField('nestedField.child'), name: 'nestedField.child' },
|
||||
indexPattern: {
|
||||
fields: [
|
||||
{
|
||||
aggregatable: true,
|
||||
esTypes: ['integer'],
|
||||
filterable: true,
|
||||
name: 'response',
|
||||
searchable: true,
|
||||
type: 'number',
|
||||
},
|
||||
],
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
},
|
||||
query: '',
|
||||
signal,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('returns "isSuggestingValues" of false if field type is boolean', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: getField('ssl'),
|
||||
})
|
||||
);
|
||||
// Note: initial `waitForNextUpdate` is hook initialization
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns "isSuggestingValues" of false to note that autocomplete service is not in use if no autocomplete suggestions available', async () => {
|
||||
const suggestionsMock = jest.fn().mockResolvedValue([]);
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: suggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: getField('bytes'),
|
||||
})
|
||||
);
|
||||
// Note: initial `waitForNextUpdate` is hook initialization
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]];
|
||||
|
||||
expect(suggestionsMock).toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns suggestions', async () => {
|
||||
await act(async () => {
|
||||
const { signal } = new AbortController();
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: getField('@tags'),
|
||||
})
|
||||
);
|
||||
// Note: initial `waitForNextUpdate` is hook initialization
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [
|
||||
false,
|
||||
true,
|
||||
['value 1', 'value 2'],
|
||||
result.current[3],
|
||||
];
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledWith({
|
||||
field: getField('@tags'),
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
query: '',
|
||||
signal,
|
||||
});
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns new suggestions on subsequent calls', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
autocompleteService: {
|
||||
...autocompleteStartMock,
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
query: '',
|
||||
selectedField: getField('@tags'),
|
||||
})
|
||||
);
|
||||
// Note: initial `waitForNextUpdate` is hook initialization
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current[3]).not.toBeNull();
|
||||
|
||||
// Added check for typescripts sake, if null,
|
||||
// would not reach below logic as test would stop above
|
||||
if (result.current[3] != null) {
|
||||
result.current[3]({
|
||||
fieldSelected: getField('@tags'),
|
||||
patterns: stubIndexPatternWithFields,
|
||||
searchQuery: '',
|
||||
value: 'hello',
|
||||
});
|
||||
}
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [
|
||||
false,
|
||||
true,
|
||||
['value 1', 'value 2'],
|
||||
result.current[3],
|
||||
];
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2);
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { AutocompleteStart } from '../../../../../../../../src/plugins/data/public';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { OperatorTypeEnum } from '../../../../../common';
|
||||
|
||||
interface FuncArgs {
|
||||
fieldSelected: IFieldType | undefined;
|
||||
patterns: IIndexPattern | undefined;
|
||||
searchQuery: string;
|
||||
value: string | string[] | undefined;
|
||||
}
|
||||
|
||||
type Func = (args: FuncArgs) => void;
|
||||
|
||||
export type UseFieldValueAutocompleteReturn = [boolean, boolean, string[], Func | null];
|
||||
|
||||
export interface UseFieldValueAutocompleteProps {
|
||||
autocompleteService: AutocompleteStart;
|
||||
fieldValue: string | string[] | undefined;
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
operatorType: OperatorTypeEnum;
|
||||
query: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
}
|
||||
/**
|
||||
* Hook for using the field value autocomplete service
|
||||
*
|
||||
*/
|
||||
export const useFieldValueAutocomplete = ({
|
||||
selectedField,
|
||||
operatorType,
|
||||
fieldValue,
|
||||
query,
|
||||
indexPattern,
|
||||
autocompleteService,
|
||||
}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuggestingValues, setIsSuggestingValues] = useState(true);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const updateSuggestions = useRef<Func | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
const fetchSuggestions = debounce(
|
||||
async ({ fieldSelected, patterns, searchQuery }: FuncArgs) => {
|
||||
try {
|
||||
if (isSubscribed) {
|
||||
if (fieldSelected == null || patterns == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fieldSelected.type === 'boolean') {
|
||||
setIsSuggestingValues(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const field =
|
||||
fieldSelected.subType != null && fieldSelected.subType.nested != null
|
||||
? {
|
||||
...fieldSelected,
|
||||
name: `${fieldSelected.subType.nested.path}.${fieldSelected.name}`,
|
||||
}
|
||||
: fieldSelected;
|
||||
|
||||
const newSuggestions = await autocompleteService.getValueSuggestions({
|
||||
field,
|
||||
indexPattern: patterns,
|
||||
query: searchQuery,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
|
||||
if (newSuggestions.length === 0) {
|
||||
setIsSuggestingValues(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setSuggestions([...newSuggestions]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSubscribed) {
|
||||
setSuggestions([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
500
|
||||
);
|
||||
|
||||
if (operatorType !== OperatorTypeEnum.EXISTS) {
|
||||
fetchSuggestions({
|
||||
fieldSelected: selectedField,
|
||||
patterns: indexPattern,
|
||||
searchQuery: query,
|
||||
value: fieldValue,
|
||||
});
|
||||
}
|
||||
|
||||
updateSuggestions.current = fetchSuggestions;
|
||||
|
||||
return (): void => {
|
||||
isSubscribed = false;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
}, [selectedField, operatorType, fieldValue, indexPattern, query, autocompleteService]);
|
||||
|
||||
return [isLoading, isSuggestingValues, suggestions, updateSuggestions.current];
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { AutocompleteFieldExistsComponent } from './field_value_exists';
|
||||
export { AutocompleteFieldListsComponent } from './field_value_lists';
|
||||
export { AutocompleteFieldMatchAnyComponent } from './field_value_match_any';
|
||||
export { AutocompleteFieldMatchComponent } from './field_value_match';
|
||||
export { FieldComponent } from './field';
|
||||
export { OperatorComponent } from './operator';
|
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
|
||||
import { OperatorComponent } from './operator';
|
||||
import { isNotOperator, isOperator } from './operators';
|
||||
|
||||
describe('OperatorComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] input`).prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={true}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<OperatorComponent
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it displays "operatorOptions" if param is passed in with items', () => {
|
||||
const wrapper = mount(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
operatorOptions={[isNotOperator]}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options')
|
||||
).toEqual([{ label: 'is not' }]);
|
||||
});
|
||||
|
||||
test('it does not display "operatorOptions" if param is passed in with no items', () => {
|
||||
const wrapper = mount(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
operatorOptions={[]}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options')
|
||||
).toEqual([
|
||||
{
|
||||
label: 'is',
|
||||
},
|
||||
{
|
||||
label: 'is not',
|
||||
},
|
||||
{
|
||||
label: 'is one of',
|
||||
},
|
||||
{
|
||||
label: 'is not one of',
|
||||
},
|
||||
{
|
||||
label: 'exists',
|
||||
},
|
||||
{
|
||||
label: 'does not exist',
|
||||
},
|
||||
{
|
||||
label: 'is in list',
|
||||
},
|
||||
{
|
||||
label: 'is not in list',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('it correctly displays selected operator', () => {
|
||||
const wrapper = mount(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={{
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['text'],
|
||||
name: 'nestedField',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
subType: { nested: { path: 'nestedField' } },
|
||||
type: 'nested',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ssl')}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<OperatorComponent
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
operator={isOperator}
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
/>
|
||||
);
|
||||
|
||||
((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' },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
import { getGenericComboBoxProps, getOperators } from './helpers';
|
||||
import { GetGenericComboBoxPropsReturn, OperatorOption } from './types';
|
||||
|
||||
const AS_PLAIN_TEXT = { asPlainText: true };
|
||||
|
||||
interface OperatorState {
|
||||
isClearable: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
onChange: (arg: OperatorOption[]) => void;
|
||||
operator: OperatorOption;
|
||||
operatorInputWidth?: number;
|
||||
operatorOptions?: OperatorOption[];
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
}
|
||||
|
||||
export const OperatorComponent: React.FC<OperatorState> = ({
|
||||
isClearable = false,
|
||||
isDisabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
operator,
|
||||
operatorOptions,
|
||||
operatorInputWidth = 150,
|
||||
placeholder,
|
||||
selectedField,
|
||||
}): JSX.Element => {
|
||||
const getLabel = useCallback(({ message }): string => message, []);
|
||||
const optionsMemo = useMemo(
|
||||
(): OperatorOption[] =>
|
||||
operatorOptions != null && operatorOptions.length > 0
|
||||
? operatorOptions
|
||||
: getOperators(selectedField),
|
||||
[operatorOptions, selectedField]
|
||||
);
|
||||
const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [
|
||||
operator,
|
||||
]);
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<OperatorOption>({
|
||||
getLabel,
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: OperatorOption[] = newOptions.map(
|
||||
({ label }) => optionsMemo[labels.indexOf(label)]
|
||||
);
|
||||
onChange(newValues);
|
||||
},
|
||||
[labels, onChange, optionsMemo]
|
||||
);
|
||||
|
||||
const inputWidth = useMemo(() => {
|
||||
return { width: `${operatorInputWidth}px` };
|
||||
}, [operatorInputWidth]);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
isClearable={isClearable}
|
||||
singleSelection={AS_PLAIN_TEXT}
|
||||
data-test-subj="operatorAutocompleteComboBox"
|
||||
style={inputWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
OperatorComponent.displayName = 'Operator';
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { OperatorEnum, OperatorTypeEnum } from '../../../../common';
|
||||
|
||||
import { OperatorOption } from './types';
|
||||
|
||||
export const isOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isOperatorLabel', {
|
||||
defaultMessage: 'is',
|
||||
}),
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'is',
|
||||
};
|
||||
|
||||
export const isNotOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isNotOperatorLabel', {
|
||||
defaultMessage: 'is not',
|
||||
}),
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'is_not',
|
||||
};
|
||||
|
||||
export const isOneOfOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isOneOfOperatorLabel', {
|
||||
defaultMessage: 'is one of',
|
||||
}),
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: 'is_one_of',
|
||||
};
|
||||
|
||||
export const isNotOneOfOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isNotOneOfOperatorLabel', {
|
||||
defaultMessage: 'is not one of',
|
||||
}),
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: 'is_not_one_of',
|
||||
};
|
||||
|
||||
export const existsOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.existsOperatorLabel', {
|
||||
defaultMessage: 'exists',
|
||||
}),
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
value: 'exists',
|
||||
};
|
||||
|
||||
export const doesNotExistOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.doesNotExistOperatorLabel', {
|
||||
defaultMessage: 'does not exist',
|
||||
}),
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
value: 'does_not_exist',
|
||||
};
|
||||
|
||||
export const isInListOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isInListOperatorLabel', {
|
||||
defaultMessage: 'is in list',
|
||||
}),
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
value: 'is_in_list',
|
||||
};
|
||||
|
||||
export const isNotInListOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.lists.exceptions.isNotInListOperatorLabel', {
|
||||
defaultMessage: 'is not in list',
|
||||
}),
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
value: 'is_not_in_list',
|
||||
};
|
||||
|
||||
export const EXCEPTION_OPERATORS: OperatorOption[] = [
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isNotOneOfOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
isInListOperator,
|
||||
isNotInListOperator,
|
||||
];
|
||||
|
||||
export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isNotOneOfOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
];
|
||||
|
||||
export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [
|
||||
isInListOperator,
|
||||
isNotInListOperator,
|
||||
];
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const LOADING = i18n.translate('xpack.lists.autocomplete.loadingDescription', {
|
||||
defaultMessage: 'Loading...',
|
||||
});
|
||||
|
||||
export const SELECT_FIELD_FIRST = i18n.translate('xpack.lists.autocomplete.selectField', {
|
||||
defaultMessage: 'Please select a field first...',
|
||||
});
|
||||
|
||||
export const FIELD_REQUIRED_ERR = i18n.translate('xpack.lists.autocomplete.fieldRequiredError', {
|
||||
defaultMessage: 'Value cannot be empty',
|
||||
});
|
||||
|
||||
export const NUMBER_ERR = i18n.translate('xpack.lists.autocomplete.invalidNumberError', {
|
||||
defaultMessage: 'Not a valid number',
|
||||
});
|
||||
|
||||
export const DATE_ERR = i18n.translate('xpack.lists.autocomplete.invalidDateError', {
|
||||
defaultMessage: 'Not a valid date',
|
||||
});
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { OperatorEnum, OperatorTypeEnum } from '../../../../common';
|
||||
|
||||
export interface GetGenericComboBoxPropsReturn {
|
||||
comboOptions: EuiComboBoxOptionOption[];
|
||||
labels: string[];
|
||||
selectedComboOptions: EuiComboBoxOptionOption[];
|
||||
}
|
||||
|
||||
export interface OperatorOption {
|
||||
message: string;
|
||||
value: string;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum;
|
||||
}
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Story, 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 { HttpStart } from 'kibana/public';
|
||||
|
||||
import { OperatorEnum, OperatorTypeEnum } from '../../../../common';
|
||||
import { AutocompleteStart } from '../../../../../../../src/plugins/data/public';
|
||||
import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
|
||||
import { BuilderEntryItem, EntryItemProps } from './entry_renderer';
|
||||
|
||||
const mockTheme = (): { darkMode: boolean; eui: unknown } => ({
|
||||
darkMode: false,
|
||||
eui: euiLightVars,
|
||||
});
|
||||
const mockAutocompleteService = ({
|
||||
getValueSuggestions: () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve([
|
||||
{
|
||||
field: {
|
||||
aggregatable: true,
|
||||
count: 30,
|
||||
esTypes: ['date'],
|
||||
name: '@timestamp',
|
||||
readFromDocValues: true,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
type: 'date',
|
||||
},
|
||||
type: 'field',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
aggregatable: true,
|
||||
count: 0,
|
||||
esTypes: ['ip'],
|
||||
name: 'ip',
|
||||
readFromDocValues: true,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
type: 'ip',
|
||||
},
|
||||
type: 'field',
|
||||
},
|
||||
]);
|
||||
}, 300);
|
||||
}),
|
||||
} as unknown) as AutocompleteStart;
|
||||
|
||||
addDecorator((storyFn) => <ThemeProvider theme={mockTheme}>{storyFn()}</ThemeProvider>);
|
||||
|
||||
export default {
|
||||
argTypes: {
|
||||
allowLargeValueLists: {
|
||||
control: {
|
||||
type: 'boolean',
|
||||
},
|
||||
description: '`boolean` - set to true to allow large value lists.',
|
||||
table: {
|
||||
defaultValue: {
|
||||
summary: false,
|
||||
},
|
||||
},
|
||||
type: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
autoCompleteService: {
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
description:
|
||||
'`AutocompleteStart` - Kibana data plugin autocomplete service used for field value autocomplete.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
entry: {
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
description: '`FormattedBuilderEntry` - A single exception item entry.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
httpService: {
|
||||
control: {
|
||||
type: 'object',
|
||||
},
|
||||
description: '`HttpStart` - Kibana service.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
indexPattern: {
|
||||
description:
|
||||
'`IIndexPattern` - index patterns used to populate field options and value autocomplete.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
listType: {
|
||||
control: {
|
||||
options: ['detection', 'endpoint'],
|
||||
type: 'select',
|
||||
},
|
||||
description:
|
||||
'`ExceptionListType` - Depending on the list type, certain validations may apply.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
onChange: {
|
||||
description:
|
||||
'`(arg: BuilderEntry, i: number) => void` - callback invoked any time field, operator or value is updated.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
onlyShowListOperators: {
|
||||
description:
|
||||
'`boolean` - set to true to display to user only operators related to large value lists. This is currently used due to limitations around combining large value list exceptions and non-large value list exceptions.',
|
||||
table: {
|
||||
defaultValue: {
|
||||
summary: false,
|
||||
},
|
||||
},
|
||||
type: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setErrorsExist: {
|
||||
description: '`(arg: boolean) => void` - callback invoked to bubble up input errors.',
|
||||
type: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
showLabel: {
|
||||
description:
|
||||
'`boolean` - whether or not to show the input labels (normally just wanted for the first entry item).',
|
||||
table: {
|
||||
defaultValue: {
|
||||
summary: false,
|
||||
},
|
||||
},
|
||||
type: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
component: BuilderEntryItem,
|
||||
title: 'BuilderEntryItem',
|
||||
};
|
||||
|
||||
const BuilderEntryItemTemplate: Story<EntryItemProps> = (args) => <BuilderEntryItem {...args} />;
|
||||
|
||||
export const Default = BuilderEntryItemTemplate.bind({});
|
||||
Default.args = {
|
||||
autocompleteService: mockAutocompleteService,
|
||||
|
||||
entry: {
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: undefined,
|
||||
id: 'e37ad550-05d2-470e-9a95-487db201ab56',
|
||||
nested: undefined,
|
||||
operator: {
|
||||
message: 'is',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'is',
|
||||
},
|
||||
parent: undefined,
|
||||
value: '',
|
||||
},
|
||||
httpService: {} as HttpStart,
|
||||
indexPattern: {
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
},
|
||||
listType: 'detection',
|
||||
onChange: action('onClick'),
|
||||
onlyShowListOperators: false,
|
||||
setErrorsExist: action('onClick'),
|
||||
showLabel: false,
|
||||
};
|
|
@ -8,47 +8,46 @@
|
|||
import { ReactWrapper, mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
|
||||
import { BuilderEntryItem } from './entry_item';
|
||||
import {
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isNotOneOfOperator,
|
||||
doesNotExistOperator,
|
||||
existsOperator,
|
||||
isInListOperator,
|
||||
isNotInListOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
} from '../../autocomplete/operators';
|
||||
isNotOneOfOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isOperator,
|
||||
} from '../autocomplete/operators';
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { getFoundListSchemaMock } from '../../../../../../lists/common/schemas/response/found_list_schema.mock';
|
||||
import { getEmptyValue } from '../../empty_value';
|
||||
import { waitFor } from '@testing-library/dom';
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks';
|
||||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { getFoundListSchemaMock } from '../../../../common/schemas/response/found_list_schema.mock';
|
||||
import { useFindLists } from '../../../lists/hooks/use_find_lists';
|
||||
|
||||
// mock out lists hook
|
||||
const mockStart = jest.fn();
|
||||
const mockResult = getFoundListSchemaMock();
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
jest.mock('../../../../lists_plugin_deps', () => {
|
||||
const originalModule = jest.requireActual('../../../../lists_plugin_deps');
|
||||
import { BuilderEntryItem } from './entry_renderer';
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
useFindLists: () => ({
|
||||
loading: false,
|
||||
start: mockStart.mockReturnValue(mockResult),
|
||||
result: mockResult,
|
||||
error: undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../lists/hooks/use_find_lists');
|
||||
|
||||
const mockKibanaHttpService = coreMock.createStart().http;
|
||||
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
|
||||
|
||||
describe('BuilderEntryItem', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
(useFindLists as jest.Mock).mockReturnValue({
|
||||
error: undefined,
|
||||
loading: false,
|
||||
result: getFoundListSchemaMock(),
|
||||
start: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
wrapper.unmount();
|
||||
|
@ -57,25 +56,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field labels if "showLabel" is "true"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: undefined,
|
||||
operator: isOperator,
|
||||
value: undefined,
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: undefined,
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: undefined,
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={true}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -85,25 +86,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -117,25 +120,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isNotOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isNotOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isNotOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -151,25 +156,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isOneOfOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOneOfOperator,
|
||||
value: ['1234'],
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOneOfOperator,
|
||||
parent: undefined,
|
||||
value: ['1234'],
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -185,25 +192,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isNotOneOfOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isNotOneOfOperator,
|
||||
value: ['1234'],
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isNotOneOfOperator,
|
||||
parent: undefined,
|
||||
value: ['1234'],
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -219,25 +228,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isInListOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isInListOperator,
|
||||
value: 'some-list-id',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isInListOperator,
|
||||
parent: undefined,
|
||||
value: 'some-list-id',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={true}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -253,25 +264,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "isNotInListOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isNotInListOperator,
|
||||
value: 'some-list-id',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isNotInListOperator,
|
||||
parent: undefined,
|
||||
value: 'some-list-id',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={true}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={true}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -287,25 +300,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "existsOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: existsOperator,
|
||||
value: undefined,
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: existsOperator,
|
||||
parent: undefined,
|
||||
value: undefined,
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -313,9 +328,7 @@ describe('BuilderEntryItem', () => {
|
|||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual(
|
||||
'exists'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual(
|
||||
getEmptyValue()
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—');
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled
|
||||
).toBeTruthy();
|
||||
|
@ -324,25 +337,27 @@ describe('BuilderEntryItem', () => {
|
|||
test('it renders field values correctly when operator is "doesNotExistOperator"', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: doesNotExistOperator,
|
||||
value: undefined,
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: doesNotExistOperator,
|
||||
parent: undefined,
|
||||
value: undefined,
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -350,9 +365,7 @@ describe('BuilderEntryItem', () => {
|
|||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"]').text()).toEqual(
|
||||
'does not exist'
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual(
|
||||
getEmptyValue()
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"]').text()).toEqual('—');
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="exceptionBuilderEntryFieldExists"] input').props().disabled
|
||||
).toBeTruthy();
|
||||
|
@ -361,57 +374,59 @@ describe('BuilderEntryItem', () => {
|
|||
test('it uses "correspondingKeywordField" if it exists', () => {
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: {
|
||||
name: 'extension.text',
|
||||
type: 'string',
|
||||
esTypes: ['text'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: false,
|
||||
aggregatable: false,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
operator: isOneOfOperator,
|
||||
value: ['1234'],
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: {
|
||||
name: 'extension',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
count: 0,
|
||||
esTypes: ['keyword'],
|
||||
name: 'extension',
|
||||
readFromDocValues: true,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
type: 'string',
|
||||
},
|
||||
entryIndex: 0,
|
||||
field: {
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['text'],
|
||||
name: 'extension.text',
|
||||
readFromDocValues: true,
|
||||
scripted: false,
|
||||
searchable: false,
|
||||
type: 'string',
|
||||
},
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOneOfOperator,
|
||||
parent: undefined,
|
||||
value: ['1234'],
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatchAny"]').prop('selectedField')
|
||||
).toEqual({
|
||||
name: 'extension',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
count: 0,
|
||||
esTypes: ['keyword'],
|
||||
name: 'extension',
|
||||
readFromDocValues: true,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
type: 'string',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -419,25 +434,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={mockOnChange}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -446,7 +463,7 @@ describe('BuilderEntryItem', () => {
|
|||
}).onChange([{ label: 'machine.os' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
{ id: '123', field: 'machine.os', operator: 'included', type: 'match', value: '' },
|
||||
{ field: 'machine.os', id: '123', operator: 'included', type: 'match', value: '' },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
@ -455,25 +472,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={mockOnChange}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -482,7 +501,7 @@ describe('BuilderEntryItem', () => {
|
|||
}).onChange([{ label: 'is not' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
{ id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '1234' },
|
||||
{ field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '1234' },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
@ -491,25 +510,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isNotOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isNotOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={mockOnChange}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -518,7 +539,7 @@ describe('BuilderEntryItem', () => {
|
|||
}).onCreateOption('126.45.211.34');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
{ id: '123', field: 'ip', operator: 'excluded', type: 'match', value: '126.45.211.34' },
|
||||
{ field: 'ip', id: '123', operator: 'excluded', type: 'match', value: '126.45.211.34' },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
@ -527,25 +548,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOneOfOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOneOfOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={mockOnChange}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -554,7 +577,7 @@ describe('BuilderEntryItem', () => {
|
|||
}).onCreateOption('126.45.211.34');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
{ id: '123', field: 'ip', operator: 'included', type: 'match_any', value: ['126.45.211.34'] },
|
||||
{ field: 'ip', id: '123', operator: 'included', type: 'match_any', value: ['126.45.211.34'] },
|
||||
0
|
||||
);
|
||||
});
|
||||
|
@ -563,25 +586,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockOnChange = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isNotInListOperator,
|
||||
value: '1234',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isNotInListOperator,
|
||||
parent: undefined,
|
||||
value: '1234',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={mockOnChange}
|
||||
setErrorsExist={jest.fn()}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -591,11 +616,11 @@ describe('BuilderEntryItem', () => {
|
|||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(
|
||||
{
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
operator: 'excluded',
|
||||
type: 'list',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
},
|
||||
0
|
||||
);
|
||||
|
@ -605,25 +630,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockSetErrorExists = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('bytes'),
|
||||
operator: isOneOfOperator,
|
||||
value: '',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('bytes'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOneOfOperator,
|
||||
parent: undefined,
|
||||
value: '',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={mockSetErrorExists}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
||||
|
@ -640,25 +667,27 @@ describe('BuilderEntryItem', () => {
|
|||
const mockSetErrorExists = jest.fn();
|
||||
wrapper = mount(
|
||||
<BuilderEntryItem
|
||||
autocompleteService={autocompleteStartMock}
|
||||
entry={{
|
||||
id: '123',
|
||||
field: getField('bytes'),
|
||||
operator: isOneOfOperator,
|
||||
value: '',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('bytes'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOneOfOperator,
|
||||
parent: undefined,
|
||||
value: '',
|
||||
}}
|
||||
httpService={mockKibanaHttpService}
|
||||
indexPattern={{
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
showLabel={false}
|
||||
listType="detection"
|
||||
onChange={jest.fn()}
|
||||
setErrorsExist={mockSetErrorExists}
|
||||
showLabel={false}
|
||||
/>
|
||||
);
|
||||
|
|
@ -6,58 +6,65 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils';
|
||||
import { Type } from '../../../../../common/detection_engine/schemas/common/schemas';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { FieldComponent } from '../../autocomplete/field';
|
||||
import { OperatorComponent } from '../../autocomplete/operator';
|
||||
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, ExceptionListType } from '../../../../lists_plugin_deps';
|
||||
import { getEmptyValue } from '../../empty_value';
|
||||
import * as i18n from './translations';
|
||||
import { AutocompleteStart } from '../../../../../../../src/plugins/data/public';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { HttpStart } from '../../../../../../../src/core/public';
|
||||
import { FieldComponent } from '../autocomplete/field';
|
||||
import { OperatorComponent } from '../autocomplete/operator';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../autocomplete/operators';
|
||||
import { AutocompleteFieldExistsComponent } from '../autocomplete/field_value_exists';
|
||||
import { AutocompleteFieldMatchComponent } from '../autocomplete/field_value_match';
|
||||
import { AutocompleteFieldMatchAnyComponent } from '../autocomplete/field_value_match_any';
|
||||
import { AutocompleteFieldListsComponent } from '../autocomplete/field_value_lists';
|
||||
import { ExceptionListType, ListSchema, OperatorTypeEnum } from '../../../../common';
|
||||
import { getEmptyValue } from '../../../common/empty_value';
|
||||
|
||||
import {
|
||||
getEntryOnFieldChange,
|
||||
getEntryOnListChange,
|
||||
getEntryOnMatchAnyChange,
|
||||
getEntryOnMatchChange,
|
||||
getEntryOnOperatorChange,
|
||||
getFilteredIndexPatterns,
|
||||
getOperatorOptions,
|
||||
getEntryOnFieldChange,
|
||||
getEntryOnOperatorChange,
|
||||
getEntryOnMatchChange,
|
||||
getEntryOnMatchAnyChange,
|
||||
getEntryOnListChange,
|
||||
} from './helpers';
|
||||
import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../../autocomplete/operators';
|
||||
import { BuilderEntry, FormattedBuilderEntry } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const MyValuesInput = styled(EuiFlexItem)`
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
interface EntryItemProps {
|
||||
export interface EntryItemProps {
|
||||
allowLargeValueLists?: boolean;
|
||||
autocompleteService: AutocompleteStart;
|
||||
entry: FormattedBuilderEntry;
|
||||
httpService: HttpStart;
|
||||
indexPattern: IIndexPattern;
|
||||
showLabel: boolean;
|
||||
listType: ExceptionListType;
|
||||
listTypeSpecificFilter?: (pattern: IIndexPattern, type: ExceptionListType) => IIndexPattern;
|
||||
onChange: (arg: BuilderEntry, i: number) => void;
|
||||
setErrorsExist: (arg: boolean) => void;
|
||||
onlyShowListOperators?: boolean;
|
||||
ruleType?: Type;
|
||||
setErrorsExist: (arg: boolean) => void;
|
||||
showLabel: boolean;
|
||||
}
|
||||
|
||||
export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
||||
allowLargeValueLists = false,
|
||||
autocompleteService,
|
||||
entry,
|
||||
httpService,
|
||||
indexPattern,
|
||||
listType,
|
||||
showLabel,
|
||||
listTypeSpecificFilter,
|
||||
onChange,
|
||||
setErrorsExist,
|
||||
onlyShowListOperators = false,
|
||||
ruleType,
|
||||
setErrorsExist,
|
||||
showLabel,
|
||||
}): JSX.Element => {
|
||||
const handleError = useCallback(
|
||||
(err: boolean): void => {
|
||||
|
@ -112,7 +119,12 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
|
||||
const renderFieldInput = useCallback(
|
||||
(isFirst: boolean): JSX.Element => {
|
||||
const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry, listType);
|
||||
const filteredIndexPatterns = getFilteredIndexPatterns(
|
||||
indexPattern,
|
||||
entry,
|
||||
listType,
|
||||
listTypeSpecificFilter
|
||||
);
|
||||
const comboBox = (
|
||||
<FieldComponent
|
||||
placeholder={
|
||||
|
@ -145,7 +157,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
);
|
||||
}
|
||||
},
|
||||
[handleFieldChange, indexPattern, entry, listType]
|
||||
[indexPattern, entry, listType, listTypeSpecificFilter, handleFieldChange]
|
||||
);
|
||||
|
||||
const renderOperatorInput = (isFirst: boolean): JSX.Element => {
|
||||
|
@ -155,7 +167,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
entry,
|
||||
listType,
|
||||
entry.field != null && entry.field.type === 'boolean',
|
||||
isFirst && !isEqlRule(ruleType) && !isThresholdRule(ruleType)
|
||||
isFirst && !allowLargeValueLists
|
||||
);
|
||||
const comboBox = (
|
||||
<OperatorComponent
|
||||
|
@ -194,6 +206,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
const value = typeof entry.value === 'string' ? entry.value : undefined;
|
||||
return (
|
||||
<AutocompleteFieldMatchComponent
|
||||
autocompleteService={autocompleteService}
|
||||
rowLabel={isFirst ? i18n.VALUE : undefined}
|
||||
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={entry.correspondingKeywordField ?? entry.field}
|
||||
|
@ -214,6 +227,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
const values: string[] = Array.isArray(entry.value) ? entry.value : [];
|
||||
return (
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
autocompleteService={autocompleteService}
|
||||
rowLabel={isFirst ? i18n.VALUE : undefined}
|
||||
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={
|
||||
|
@ -238,6 +252,7 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
const id = typeof entry.value === 'string' ? entry.value : undefined;
|
||||
return (
|
||||
<AutocompleteFieldListsComponent
|
||||
httpService={httpService}
|
||||
rowLabel={isFirst ? i18n.VALUE : undefined}
|
||||
selectedField={entry.field}
|
||||
placeholder={i18n.EXCEPTION_FIELD_LISTS_PLACEHOLDER}
|
||||
|
@ -248,7 +263,6 @@ export const BuilderEntryItem: React.FC<EntryItemProps> = ({
|
|||
}
|
||||
isClearable={false}
|
||||
onChange={handleFieldListValueChange}
|
||||
isRequired
|
||||
data-test-subj="exceptionBuilderEntryFieldList"
|
||||
/>
|
||||
);
|
|
@ -0,0 +1,998 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { getEntryNestedMock } from '../../../../common/schemas/types/entry_nested.mock';
|
||||
import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock';
|
||||
import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock';
|
||||
import { getListResponseMock } from '../../../../common/schemas/response/list_schema.mock';
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
EXCEPTION_OPERATORS_SANS_LISTS,
|
||||
doesNotExistOperator,
|
||||
existsOperator,
|
||||
isInListOperator,
|
||||
isNotOneOfOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isOperator,
|
||||
} from '../autocomplete/operators';
|
||||
import {
|
||||
EntryExists,
|
||||
EntryList,
|
||||
EntryMatch,
|
||||
EntryMatchAny,
|
||||
EntryNested,
|
||||
ExceptionListType,
|
||||
OperatorEnum,
|
||||
OperatorTypeEnum,
|
||||
} from '../../../../common';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
|
||||
import { BuilderEntry, FormattedBuilderEntry } from './types';
|
||||
import {
|
||||
getEntryFromOperator,
|
||||
getEntryOnFieldChange,
|
||||
getEntryOnListChange,
|
||||
getEntryOnMatchAnyChange,
|
||||
getEntryOnMatchChange,
|
||||
getEntryOnOperatorChange,
|
||||
getFilteredIndexPatterns,
|
||||
getOperatorOptions,
|
||||
} from './helpers';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn().mockReturnValue('123'),
|
||||
}));
|
||||
|
||||
const getEntryNestedWithIdMock = (): EntryNested & { id: string } => ({
|
||||
...getEntryNestedMock(),
|
||||
id: '123',
|
||||
});
|
||||
|
||||
const getEntryMatchWithIdMock = (): EntryMatch & { id: string } => ({
|
||||
...getEntryMatchMock(),
|
||||
id: '123',
|
||||
});
|
||||
|
||||
const getEntryMatchAnyWithIdMock = (): EntryMatchAny & { id: string } => ({
|
||||
...getEntryMatchAnyMock(),
|
||||
id: '123',
|
||||
});
|
||||
|
||||
const getMockIndexPattern = (): IIndexPattern => ({
|
||||
fields,
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
});
|
||||
|
||||
const getMockBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('ip'),
|
||||
id: '123',
|
||||
nested: undefined,
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: 'some value',
|
||||
});
|
||||
|
||||
const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getField('nestedField.child'),
|
||||
id: '123',
|
||||
nested: 'child',
|
||||
operator: isOperator,
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
|
||||
field: 'nestedField',
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
value: 'some value',
|
||||
});
|
||||
|
||||
const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: { ...getField('nestedField.child'), esTypes: ['nested'], name: 'nestedField' },
|
||||
id: '123',
|
||||
nested: 'parent',
|
||||
operator: isOperator,
|
||||
parent: undefined,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
const mockEndpointFields = [
|
||||
{
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['keyword'],
|
||||
name: 'file.path.caseless',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['text'],
|
||||
name: 'file.Ext.code_signature.status',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
subType: { nested: { path: 'file.Ext.code_signature' } },
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
|
||||
export const getEndpointField = (name: string): IFieldType =>
|
||||
mockEndpointFields.find((field) => field.name === name) as IFieldType;
|
||||
|
||||
const filterIndexPatterns = (patterns: IIndexPattern, type: ExceptionListType): IIndexPattern => {
|
||||
return type === 'endpoint'
|
||||
? {
|
||||
...patterns,
|
||||
fields: patterns.fields.filter(({ name }) =>
|
||||
['file.path.caseless', 'file.Ext.code_signature.status'].includes(name)
|
||||
),
|
||||
}
|
||||
: patterns;
|
||||
};
|
||||
|
||||
describe('Exception builder helpers', () => {
|
||||
describe('#getFilteredIndexPatterns', () => {
|
||||
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'), name: 'child' }],
|
||||
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'), esTypes: ['nested'], name: 'nestedField' }],
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
correspondingKeywordField: undefined,
|
||||
entryIndex: 0,
|
||||
field: getEndpointField('file.Ext.code_signature.status'),
|
||||
id: '123',
|
||||
nested: 'child',
|
||||
operator: isOperator,
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
|
||||
field: 'file.Ext.code_signature',
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
value: 'some value',
|
||||
};
|
||||
const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint');
|
||||
const expected: IIndexPattern = {
|
||||
fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: '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'),
|
||||
esTypes: ['nested'],
|
||||
name: 'file.Ext.code_signature',
|
||||
},
|
||||
};
|
||||
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',
|
||||
filterIndexPatterns
|
||||
);
|
||||
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',
|
||||
filterIndexPatterns
|
||||
);
|
||||
const expected: IIndexPattern = {
|
||||
fields: [
|
||||
{
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['keyword'],
|
||||
name: 'file.path.caseless',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
aggregatable: false,
|
||||
count: 0,
|
||||
esTypes: ['text'],
|
||||
name: 'file.Ext.code_signature.status',
|
||||
readFromDocValues: false,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
subType: { nested: { path: 'file.Ext.code_signature' } },
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryFromOperator', () => {
|
||||
test('it returns current value when switching from "is" to "is not"', () => {
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: 'excluded',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is not" to "is"', () => {
|
||||
const payloadOperator: OperatorOption = isOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "match"', () => {
|
||||
const payloadOperator: OperatorOption = isOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is one of" to "is not one of"', () => {
|
||||
const payloadOperator: OperatorOption = isNotOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: 'excluded',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is not one of" to "is one of"', () => {
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "match_any"', () => {
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "exists" to "does not exist"', () => {
|
||||
const payloadOperator: OperatorOption = doesNotExistOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: existsOperator,
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: 'excluded',
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "does not exist" to "exists"', () => {
|
||||
const payloadOperator: OperatorOption = existsOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: doesNotExistOperator,
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "exists"', () => {
|
||||
const payloadOperator: OperatorOption = existsOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "list"', () => {
|
||||
const payloadOperator: OperatorOption = isInListOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryList & { id?: string } = {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
list: { id: '', type: 'ip' },
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'list',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getOperatorOptions', () => {
|
||||
test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" if no field selected', () => {
|
||||
const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', true);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', true);
|
||||
const expected: OperatorOption[] = [isOperator, existsOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns all operator options if "listType" is "detection"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false);
|
||||
const expected: OperatorOption[] = EXCEPTION_OPERATORS;
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', true);
|
||||
const expected: OperatorOption[] = [
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns list operators if specified to', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false, true);
|
||||
expect(output).toEqual(EXCEPTION_OPERATORS);
|
||||
});
|
||||
|
||||
test('it does not return list operators if specified not to', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false, false);
|
||||
expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnFieldChange', () => {
|
||||
test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
|
||||
const payloadIFieldType: IFieldType = getField('nestedField.child');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
entries: [
|
||||
{ ...getEntryMatchWithIdMock(), field: 'child' },
|
||||
getEntryMatchAnyWithIdMock(),
|
||||
],
|
||||
field: 'nestedField',
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const payloadIFieldType: IFieldType = getField('nestedField.child');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
getEntryMatchAnyWithIdMock(),
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns field of type "match" with updated field if not a nested entry', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadIFieldType: IFieldType = getField('ip');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnOperatorChange', () => {
|
||||
test('it returns updated subentry preserving its value when entry is not switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: 'excluded',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'some value',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry resetting its value when entry is switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'some value',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnMatchChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: '',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined };
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: '',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnMatchAnyChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: ['some value'],
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
field: undefined,
|
||||
operator: isOneOfOperator,
|
||||
value: ['some value'],
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: '',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }],
|
||||
field: 'nestedField',
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: 'child',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
field: undefined,
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }],
|
||||
field: 'nestedField',
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
{
|
||||
field: '',
|
||||
id: '123',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
id: '123',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnListChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: '1234',
|
||||
};
|
||||
const output = getEntryOnListChange(payload, getListResponseMock());
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: 'ip',
|
||||
id: '123',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'list',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
field: undefined,
|
||||
operator: isOneOfOperator,
|
||||
value: '1234',
|
||||
};
|
||||
const output = getEntryOnListChange(payload, getListResponseMock());
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
field: '',
|
||||
id: '123',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'list',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,389 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/public';
|
||||
import { addIdToItem } from '../../../../common/shared_imports';
|
||||
import {
|
||||
Entry,
|
||||
ExceptionListType,
|
||||
ListSchema,
|
||||
OperatorTypeEnum,
|
||||
entriesList,
|
||||
} from '../../../../common';
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
EXCEPTION_OPERATORS_SANS_LISTS,
|
||||
doesNotExistOperator,
|
||||
existsOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isOperator,
|
||||
} from '../autocomplete/operators';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
|
||||
import { BuilderEntry, FormattedBuilderEntry } from './types';
|
||||
|
||||
/**
|
||||
* Returns filtered index patterns based on the field - if a user selects to
|
||||
* add nested entry, should only show nested fields, if item is the parent
|
||||
* field of a nested entry, we only display the parent field
|
||||
*
|
||||
* @param patterns IIndexPattern containing available fields on rule index
|
||||
* @param item exception item entry
|
||||
* set to add a nested field
|
||||
*/
|
||||
export const getFilteredIndexPatterns = (
|
||||
patterns: IIndexPattern,
|
||||
item: FormattedBuilderEntry,
|
||||
type: ExceptionListType,
|
||||
preFilter?: (i: IIndexPattern, t: ExceptionListType) => IIndexPattern
|
||||
): IIndexPattern => {
|
||||
const indexPatterns = preFilter != null ? preFilter(patterns, type) : patterns;
|
||||
|
||||
if (item.nested === 'child' && item.parent != null) {
|
||||
// when user has selected a nested entry, only fields with the common parent are shown
|
||||
return {
|
||||
...indexPatterns,
|
||||
fields: indexPatterns.fields
|
||||
.filter((indexField) => {
|
||||
const fieldHasCommonParentPath =
|
||||
indexField.subType != null &&
|
||||
indexField.subType.nested != null &&
|
||||
item.parent != null &&
|
||||
indexField.subType.nested.path === item.parent.parent.field;
|
||||
|
||||
return fieldHasCommonParentPath;
|
||||
})
|
||||
.map((f) => {
|
||||
const [fieldNameWithoutParentPath] = f.name.split('.').slice(-1);
|
||||
return { ...f, name: fieldNameWithoutParentPath };
|
||||
}),
|
||||
};
|
||||
} else if (item.nested === 'parent' && item.field != null) {
|
||||
// when user has selected a nested entry, right above it we show the common parent
|
||||
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 {
|
||||
...indexPatterns,
|
||||
fields: indexPatterns.fields.filter(
|
||||
(field) => field.subType != null && field.subType.nested != null
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return indexPatterns;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user selects new field
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly selected field
|
||||
*
|
||||
*/
|
||||
export const getEntryOnFieldChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: IFieldType
|
||||
): { index: number; updatedEntry: BuilderEntry } => {
|
||||
const { parent, entryIndex, nested } = item;
|
||||
const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
if (nested === 'parent') {
|
||||
// For nested entries, when user first selects to add a nested
|
||||
// entry, they first see a row similar to what is shown for when
|
||||
// a user selects "exists", as soon as they make a selection
|
||||
// we can now identify the 'parent' and 'child' this is where
|
||||
// we first convert the entry into type "nested"
|
||||
const newParentFieldValue =
|
||||
newField.subType != null && newField.subType.nested != null
|
||||
? newField.subType.nested.path
|
||||
: '';
|
||||
|
||||
return {
|
||||
index: entryIndex,
|
||||
updatedEntry: {
|
||||
entries: [
|
||||
addIdToItem({
|
||||
field: newChildFieldValue ?? '',
|
||||
operator: isOperator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
}),
|
||||
],
|
||||
field: newParentFieldValue,
|
||||
id: item.id,
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
} else if (nested === 'child' && parent != null) {
|
||||
return {
|
||||
index: parent.parentIndex,
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
field: newChildFieldValue ?? '',
|
||||
id: item.id,
|
||||
operator: isOperator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
index: entryIndex,
|
||||
updatedEntry: {
|
||||
field: newField != null ? newField.name : '',
|
||||
id: item.id,
|
||||
operator: isOperator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "list"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly selected list
|
||||
*
|
||||
*/
|
||||
export const getEntryOnListChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: ListSchema
|
||||
): { index: number; updatedEntry: BuilderEntry } => {
|
||||
const { entryIndex, field, operator } = item;
|
||||
const { id, type } = newField;
|
||||
|
||||
return {
|
||||
index: entryIndex,
|
||||
updatedEntry: {
|
||||
field: field != null ? field.name : '',
|
||||
id: item.id,
|
||||
list: { id, type },
|
||||
operator: operator.operator,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "match_any"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly entered value
|
||||
*
|
||||
*/
|
||||
export const getEntryOnMatchAnyChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: string[]
|
||||
): { index: number; updatedEntry: BuilderEntry } => {
|
||||
const { nested, parent, entryIndex, field, operator } = item;
|
||||
|
||||
if (nested != null && parent != null) {
|
||||
const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
return {
|
||||
index: parent.parentIndex,
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
field: fieldName,
|
||||
id: item.id,
|
||||
operator: operator.operator,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: newField,
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
index: entryIndex,
|
||||
updatedEntry: {
|
||||
field: field != null ? field.name : '',
|
||||
id: item.id,
|
||||
operator: operator.operator,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: newField,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "match"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly entered value
|
||||
*
|
||||
*/
|
||||
export const getEntryOnMatchChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: string
|
||||
): { index: number; updatedEntry: BuilderEntry } => {
|
||||
const { nested, parent, entryIndex, field, operator } = item;
|
||||
|
||||
if (nested != null && parent != null) {
|
||||
const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
return {
|
||||
index: parent.parentIndex,
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
field: fieldName,
|
||||
id: item.id,
|
||||
operator: operator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: newField,
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
index: entryIndex,
|
||||
updatedEntry: {
|
||||
field: field != null ? field.name : '',
|
||||
id: item.id,
|
||||
operator: operator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: newField,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On operator change, determines whether value needs to be cleared or not
|
||||
*
|
||||
* @param field
|
||||
* @param selectedOperator
|
||||
* @param currentEntry
|
||||
*
|
||||
*/
|
||||
export const getEntryFromOperator = (
|
||||
selectedOperator: OperatorOption,
|
||||
currentEntry: FormattedBuilderEntry
|
||||
): Entry & { id?: string } => {
|
||||
const isSameOperatorType = currentEntry.operator.type === selectedOperator.type;
|
||||
const fieldValue = currentEntry.field != null ? currentEntry.field.name : '';
|
||||
switch (selectedOperator.type) {
|
||||
case 'match':
|
||||
return {
|
||||
field: fieldValue,
|
||||
id: currentEntry.id,
|
||||
operator: selectedOperator.operator,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value:
|
||||
isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '',
|
||||
};
|
||||
case 'match_any':
|
||||
return {
|
||||
field: fieldValue,
|
||||
id: currentEntry.id,
|
||||
operator: selectedOperator.operator,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [],
|
||||
};
|
||||
case 'list':
|
||||
return {
|
||||
field: fieldValue,
|
||||
id: currentEntry.id,
|
||||
list: { id: '', type: 'ip' },
|
||||
operator: selectedOperator.operator,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
field: fieldValue,
|
||||
id: currentEntry.id,
|
||||
operator: selectedOperator.operator,
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user selects new operator
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newOperator - newly selected operator
|
||||
*
|
||||
*/
|
||||
export const getEntryOnOperatorChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newOperator: OperatorOption
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { parent, entryIndex, field, nested } = item;
|
||||
const newEntry = getEntryFromOperator(newOperator, item);
|
||||
|
||||
if (!entriesList.is(newEntry) && nested != null && parent != null) {
|
||||
return {
|
||||
index: parent.parentIndex,
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
...newEntry,
|
||||
field: field != null ? field.name.split('.').slice(-1)[0] : '',
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return { index: entryIndex, updatedEntry: newEntry };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines which operators to make available
|
||||
*
|
||||
* @param item
|
||||
* @param listType
|
||||
* @param isBoolean
|
||||
* @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators
|
||||
*/
|
||||
export const getOperatorOptions = (
|
||||
item: FormattedBuilderEntry,
|
||||
listType: ExceptionListType,
|
||||
isBoolean: boolean,
|
||||
includeValueListOperators = true
|
||||
): OperatorOption[] => {
|
||||
if (item.nested === 'parent' || item.field == null) {
|
||||
return [isOperator];
|
||||
} else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') {
|
||||
return isBoolean ? [isOperator] : [isOperator, isOneOfOperator];
|
||||
} else if (item.nested != null && listType === 'detection') {
|
||||
return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator];
|
||||
} else {
|
||||
return isBoolean
|
||||
? [isOperator, isNotOperator, existsOperator, doesNotExistOperator]
|
||||
: includeValueListOperators
|
||||
? EXCEPTION_OPERATORS
|
||||
: EXCEPTION_OPERATORS_SANS_LISTS;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const FIELD = i18n.translate('xpack.lists.exceptions.builder.fieldLabel', {
|
||||
defaultMessage: 'Field',
|
||||
});
|
||||
|
||||
export const OPERATOR = i18n.translate('xpack.lists.exceptions.builder.operatorLabel', {
|
||||
defaultMessage: 'Operator',
|
||||
});
|
||||
|
||||
export const VALUE = i18n.translate('xpack.lists.exceptions.builder.valueLabel', {
|
||||
defaultMessage: 'Value',
|
||||
});
|
||||
|
||||
export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate(
|
||||
'xpack.lists.exceptions.builder.exceptionFieldValuePlaceholder',
|
||||
{
|
||||
defaultMessage: 'Search field value...',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate(
|
||||
'xpack.lists.exceptions.builder.exceptionFieldNestedPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Search nested field',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate(
|
||||
'xpack.lists.exceptions.builder.exceptionListsPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Search for list...',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate(
|
||||
'xpack.lists.exceptions.builder.exceptionFieldPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Search',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate(
|
||||
'xpack.lists.exceptions.builder.exceptionOperatorPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Operator',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
import {
|
||||
CreateExceptionListItemSchema,
|
||||
Entry,
|
||||
EntryExists,
|
||||
EntryMatch,
|
||||
EntryMatchAny,
|
||||
EntryNested,
|
||||
ExceptionListItemSchema,
|
||||
OperatorEnum,
|
||||
OperatorTypeEnum,
|
||||
} from '../../../../common';
|
||||
|
||||
export interface FormattedBuilderEntry {
|
||||
id: string;
|
||||
field: IFieldType | undefined;
|
||||
operator: OperatorOption;
|
||||
value: string | string[] | undefined;
|
||||
nested: 'parent' | 'child' | undefined;
|
||||
entryIndex: number;
|
||||
parent: { parent: BuilderEntryNested; parentIndex: number } | undefined;
|
||||
correspondingKeywordField: IFieldType | undefined;
|
||||
}
|
||||
|
||||
export interface EmptyEntry {
|
||||
id: string;
|
||||
field: string | undefined;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY;
|
||||
value: string | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface EmptyListEntry {
|
||||
id: string;
|
||||
field: string | undefined;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum.LIST;
|
||||
list: { id: string | undefined; type: string | undefined };
|
||||
}
|
||||
|
||||
export interface EmptyNestedEntry {
|
||||
id: string;
|
||||
field: string | undefined;
|
||||
type: OperatorTypeEnum.NESTED;
|
||||
entries: Array<
|
||||
| (EntryMatch & { id?: string })
|
||||
| (EntryMatchAny & { id?: string })
|
||||
| (EntryExists & { id?: string })
|
||||
>;
|
||||
}
|
||||
|
||||
export type BuilderEntry =
|
||||
| (Entry & { id?: string })
|
||||
| EmptyListEntry
|
||||
| EmptyEntry
|
||||
| BuilderEntryNested
|
||||
| EmptyNestedEntry;
|
||||
|
||||
export type BuilderEntryNested = Omit<EntryNested, 'entries'> & {
|
||||
id?: string;
|
||||
entries: Array<
|
||||
| (EntryMatch & { id?: string })
|
||||
| (EntryMatchAny & { id?: string })
|
||||
| (EntryExists & { id?: string })
|
||||
>;
|
||||
};
|
||||
|
||||
export type ExceptionListItemBuilderSchema = Omit<ExceptionListItemSchema, 'entries'> & {
|
||||
entries: BuilderEntry[];
|
||||
};
|
||||
|
||||
export type CreateExceptionListItemBuilderSchema = Omit<
|
||||
CreateExceptionListItemSchema,
|
||||
'meta' | 'entries'
|
||||
> & {
|
||||
meta: { temporaryUuid: string };
|
||||
entries: BuilderEntry[];
|
||||
};
|
||||
|
||||
export type ExceptionsBuilderExceptionItem =
|
||||
| ExceptionListItemBuilderSchema
|
||||
| CreateExceptionListItemBuilderSchema;
|
|
@ -38,3 +38,4 @@ export {
|
|||
UseExceptionListItemsSuccess,
|
||||
UseExceptionListsSuccess,
|
||||
} from './exceptions/types';
|
||||
export { BuilderEntryItem } from './exceptions/components/builder/entry_renderer';
|
||||
|
|
14
x-pack/plugins/lists/scripts/storybook.js
Normal file
14
x-pack/plugins/lists/scripts/storybook.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
// eslint-disable-next-line
|
||||
require('@kbn/storybook').runStorybookCli({
|
||||
name: 'lists',
|
||||
storyGlobs: [join(__dirname, '..', 'public', '**', '*.stories.tsx')],
|
||||
});
|
|
@ -133,7 +133,7 @@ describe('Exceptions modal', () => {
|
|||
closeExceptionBuilderModal();
|
||||
});
|
||||
|
||||
it.skip('Does not overwrite values of nested entry items', () => {
|
||||
it('Does not overwrite values of nested entry items', () => {
|
||||
openExceptionModalFromRuleSettings();
|
||||
cy.get(LOADING_SPINNER).should('not.exist');
|
||||
|
||||
|
@ -144,13 +144,14 @@ describe('Exceptions modal', () => {
|
|||
|
||||
// exception item 2 with nested field
|
||||
cy.get(ADD_OR_BTN).click();
|
||||
addExceptionEntryFieldValueOfItemX('c', 1, 0);
|
||||
addExceptionEntryFieldValueOfItemX('agent.name', 1, 0);
|
||||
cy.get(ADD_NESTED_BTN).click();
|
||||
addExceptionEntryFieldValueOfItemX('user.id{downarrow}{enter}', 1, 1);
|
||||
cy.get(ADD_AND_BTN).click();
|
||||
addExceptionEntryFieldValueOfItemX('last{downarrow}{enter}', 1, 3);
|
||||
// This button will now read `Add non-nested button`
|
||||
cy.get(ADD_NESTED_BTN).click();
|
||||
cy.get(ADD_NESTED_BTN).scrollIntoView();
|
||||
cy.get(ADD_NESTED_BTN).focus().click();
|
||||
addExceptionEntryFieldValueOfItemX('@timestamp', 1, 4);
|
||||
|
||||
// should have only deleted `user.id`
|
||||
|
@ -161,7 +162,11 @@ describe('Exceptions modal', () => {
|
|||
.eq(0)
|
||||
.should('have.text', 'agent.name');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER)
|
||||
.eq(1)
|
||||
.find(FIELD_INPUT)
|
||||
.eq(0)
|
||||
.should('have.text', 'agent.name');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER)
|
||||
|
@ -178,7 +183,11 @@ describe('Exceptions modal', () => {
|
|||
.eq(0)
|
||||
.should('have.text', 'agent.name');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(0).should('have.text', 'c');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER)
|
||||
.eq(1)
|
||||
.find(FIELD_INPUT)
|
||||
.eq(0)
|
||||
.should('have.text', 'agent.name');
|
||||
cy.get(EXCEPTION_ITEM_CONTAINER)
|
||||
.eq(1)
|
||||
.find(FIELD_INPUT)
|
||||
|
|
|
@ -55,7 +55,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
<ThemeProvider theme={mockTheme}>
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={0}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -84,7 +83,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
<ThemeProvider theme={mockTheme}>
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={1}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -111,7 +109,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
<ThemeProvider theme={mockTheme}>
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={1}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -140,7 +137,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
<ThemeProvider theme={mockTheme}>
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={1}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -176,7 +172,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
const wrapper = mount(
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={0}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -204,7 +199,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
const wrapper = mount(
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={0}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -231,7 +225,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
const wrapper = mount(
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={1}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -260,7 +253,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
const wrapper = mount(
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={0}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
@ -289,7 +281,6 @@ describe('BuilderExceptionListItemComponent', () => {
|
|||
const wrapper = mount(
|
||||
<BuilderExceptionListItemComponent
|
||||
exceptionItem={exceptionItem}
|
||||
exceptionId={'123'}
|
||||
exceptionItemIndex={0}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
|
|
|
@ -13,10 +13,11 @@ import { Type } from '../../../../../common/detection_engine/schemas/common/sche
|
|||
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers';
|
||||
import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types';
|
||||
import { ExceptionListType } from '../../../../../public/lists_plugin_deps';
|
||||
import { BuilderEntryItem } from './entry_item';
|
||||
import { BuilderEntryItem, ExceptionListType } from '../../../../shared_imports';
|
||||
import { BuilderEntryDeleteButtonComponent } from './entry_delete_button';
|
||||
import { BuilderAndBadgeComponent } from './and_badge';
|
||||
import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils';
|
||||
import { useKibana } from '../../../lib/kibana';
|
||||
|
||||
const MyBeautifulLine = styled(EuiFlexItem)`
|
||||
&:after {
|
||||
|
@ -35,7 +36,6 @@ const MyOverflowContainer = styled(EuiFlexItem)`
|
|||
|
||||
interface BuilderExceptionListItemProps {
|
||||
exceptionItem: ExceptionsBuilderExceptionItem;
|
||||
exceptionId: string;
|
||||
exceptionItemIndex: number;
|
||||
indexPattern: IIndexPattern;
|
||||
andLogicIncluded: boolean;
|
||||
|
@ -51,7 +51,6 @@ interface BuilderExceptionListItemProps {
|
|||
export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionListItemProps>(
|
||||
({
|
||||
exceptionItem,
|
||||
exceptionId,
|
||||
exceptionItemIndex,
|
||||
indexPattern,
|
||||
isOnlyItem,
|
||||
|
@ -63,6 +62,7 @@ export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionList
|
|||
onlyShowListOperators = false,
|
||||
ruleType,
|
||||
}) => {
|
||||
const { http, data } = useKibana().services;
|
||||
const handleEntryChange = useCallback(
|
||||
(entry: BuilderEntry, entryIndex: number): void => {
|
||||
const updatedEntries: BuilderEntry[] = [
|
||||
|
@ -119,6 +119,9 @@ export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionList
|
|||
{item.nested === 'child' && <MyBeautifulLine grow={false} />}
|
||||
<MyOverflowContainer grow={1}>
|
||||
<BuilderEntryItem
|
||||
allowLargeValueLists={!isEqlRule(ruleType) && !isThresholdRule(ruleType)}
|
||||
httpService={http}
|
||||
autocompleteService={data.autocomplete}
|
||||
entry={item}
|
||||
indexPattern={indexPattern}
|
||||
listType={listType}
|
||||
|
@ -128,7 +131,6 @@ export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionList
|
|||
onChange={handleEntryChange}
|
||||
setErrorsExist={setErrorsExist}
|
||||
onlyShowListOperators={onlyShowListOperators}
|
||||
ruleType={ruleType}
|
||||
/>
|
||||
</MyOverflowContainer>
|
||||
<BuilderEntryDeleteButtonComponent
|
||||
|
|
|
@ -14,46 +14,19 @@ import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/
|
|||
import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
|
||||
import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock';
|
||||
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
|
||||
import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock';
|
||||
import {
|
||||
doesNotExistOperator,
|
||||
EXCEPTION_OPERATORS,
|
||||
EXCEPTION_OPERATORS_SANS_LISTS,
|
||||
existsOperator,
|
||||
isInListOperator,
|
||||
isNotOneOfOperator,
|
||||
isNotOperator,
|
||||
isOneOfOperator,
|
||||
isOperator,
|
||||
} from '../../autocomplete/operators';
|
||||
import { isOneOfOperator, isOperator } from '../../autocomplete/operators';
|
||||
import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import {
|
||||
EntryMatch,
|
||||
EntryMatchAny,
|
||||
EntryNested,
|
||||
EntryList,
|
||||
EntryExists,
|
||||
OperatorTypeEnum,
|
||||
OperatorEnum,
|
||||
} from '../../../../shared_imports';
|
||||
import { EntryNested, OperatorTypeEnum, OperatorEnum } from '../../../../shared_imports';
|
||||
|
||||
import {
|
||||
getEntryFromOperator,
|
||||
getEntryOnFieldChange,
|
||||
getEntryOnListChange,
|
||||
getEntryOnMatchAnyChange,
|
||||
getEntryOnMatchChange,
|
||||
getEntryOnOperatorChange,
|
||||
getFilteredIndexPatterns,
|
||||
filterIndexPatterns,
|
||||
getFormattedBuilderEntries,
|
||||
getFormattedBuilderEntry,
|
||||
getOperatorOptions,
|
||||
getUpdatedEntriesOnDelete,
|
||||
isEntryNested,
|
||||
getCorrespondingKeywordField,
|
||||
} from './helpers';
|
||||
import { OperatorOption } from '../../autocomplete/types';
|
||||
import { ENTRIES_WITH_IDS } from '../../../../../../lists/common/constants.mock';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
|
@ -86,46 +59,6 @@ const getMockIndexPattern = (): IIndexPattern => ({
|
|||
fields,
|
||||
});
|
||||
|
||||
const getMockBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
id: '123',
|
||||
field: getField('ip'),
|
||||
operator: isOperator,
|
||||
value: 'some value',
|
||||
nested: undefined,
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
});
|
||||
|
||||
const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
id: '123',
|
||||
field: getField('nestedField.child'),
|
||||
operator: isOperator,
|
||||
value: 'some value',
|
||||
nested: 'child',
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
field: 'nestedField',
|
||||
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
});
|
||||
|
||||
const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({
|
||||
id: '123',
|
||||
field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] },
|
||||
operator: isOperator,
|
||||
value: undefined,
|
||||
nested: 'parent',
|
||||
parent: undefined,
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
});
|
||||
|
||||
const mockEndpointFields = [
|
||||
{
|
||||
name: 'file.path.caseless',
|
||||
|
@ -154,6 +87,24 @@ export const getEndpointField = (name: string) =>
|
|||
mockEndpointFields.find((field) => field.name === name) as IFieldType;
|
||||
|
||||
describe('Exception builder helpers', () => {
|
||||
describe('#filterIndexPatterns', () => {
|
||||
test('it returns index patterns without filtering if list type is "detection"', () => {
|
||||
const mockIndexPatterns = getMockIndexPattern();
|
||||
const output = filterIndexPatterns(mockIndexPatterns, 'detection');
|
||||
|
||||
expect(output).toEqual(mockIndexPatterns);
|
||||
});
|
||||
|
||||
test('it returns filtered index patterns if list type is "endpoint"', () => {
|
||||
const mockIndexPatterns = {
|
||||
...getMockIndexPattern(),
|
||||
fields: [...fields, ...mockEndpointFields],
|
||||
};
|
||||
const output = filterIndexPatterns(mockIndexPatterns, 'endpoint');
|
||||
|
||||
expect(output).toEqual({ ...getMockIndexPattern(), fields: [...mockEndpointFields] });
|
||||
});
|
||||
});
|
||||
describe('#getCorrespondingKeywordField', () => {
|
||||
test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => {
|
||||
const output = getCorrespondingKeywordField({
|
||||
|
@ -192,183 +143,6 @@ describe('Exception builder helpers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#getFilteredIndexPatterns', () => {
|
||||
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'), name: 'child' }],
|
||||
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, '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);
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
id: '123',
|
||||
field: getEndpointField('file.Ext.code_signature.status'),
|
||||
operator: isOperator,
|
||||
value: 'some value',
|
||||
nested: 'child',
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
field: 'file.Ext.code_signature',
|
||||
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
entryIndex: 0,
|
||||
correspondingKeywordField: undefined,
|
||||
};
|
||||
const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem, 'endpoint');
|
||||
const expected: IIndexPattern = {
|
||||
fields: [{ ...getEndpointField('file.Ext.code_signature.status'), name: '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: ['keyword'],
|
||||
name: 'file.path.caseless',
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
|
@ -748,660 +522,4 @@ describe('Exception builder helpers', () => {
|
|||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryFromOperator', () => {
|
||||
test('it returns current value when switching from "is" to "is not"', () => {
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: 'excluded',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is not" to "is"', () => {
|
||||
const payloadOperator: OperatorOption = isOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "match"', () => {
|
||||
const payloadOperator: OperatorOption = isOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatch & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is one of" to "is not one of"', () => {
|
||||
const payloadOperator: OperatorOption = isNotOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: 'excluded',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "is not one of" to "is one of"', () => {
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isNotOneOfOperator,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['I should stay the same'],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "match_any"', () => {
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryMatchAny & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "exists" to "does not exist"', () => {
|
||||
const payloadOperator: OperatorOption = doesNotExistOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: existsOperator,
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: 'excluded',
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns current value when switching from "does not exist" to "exists"', () => {
|
||||
const payloadOperator: OperatorOption = existsOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: doesNotExistOperator,
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "exists"', () => {
|
||||
const payloadOperator: OperatorOption = existsOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryExists & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'exists',
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns empty value when switching operator types to "list"', () => {
|
||||
const payloadOperator: OperatorOption = isInListOperator;
|
||||
const payloadEntry: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOperator,
|
||||
value: 'I should stay the same',
|
||||
};
|
||||
const output = getEntryFromOperator(payloadOperator, payloadEntry);
|
||||
const expected: EntryList & { id?: string } = {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: 'list',
|
||||
list: { id: '', type: 'ip' },
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getOperatorOptions', () => {
|
||||
test('it returns "isOperator" when field type is nested but field itself has not yet been selected', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" if no field selected', () => {
|
||||
const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'endpoint', true);
|
||||
const expected: OperatorOption[] = [isOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false);
|
||||
const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', true);
|
||||
const expected: OperatorOption[] = [isOperator, existsOperator];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns all operator options if "listType" is "detection"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false);
|
||||
const expected: OperatorOption[] = EXCEPTION_OPERATORS;
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns "isOperator", "isNotOperator", "doesNotExistOperator" and "existsOperator" if field type is boolean', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', true);
|
||||
const expected: OperatorOption[] = [
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
];
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns list operators if specified to', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false, true);
|
||||
expect(output).toEqual(EXCEPTION_OPERATORS);
|
||||
});
|
||||
|
||||
test('it does not return list operators if specified not to', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getOperatorOptions(payloadItem, 'detection', false, false);
|
||||
expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnFieldChange', () => {
|
||||
test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry();
|
||||
const payloadIFieldType: IFieldType = getField('nestedField.child');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => {
|
||||
const payloadItem: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
field: 'nestedField',
|
||||
entries: [
|
||||
{ ...getEntryMatchWithIdMock(), field: 'child' },
|
||||
getEntryMatchAnyWithIdMock(),
|
||||
],
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const payloadIFieldType: IFieldType = getField('nestedField.child');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
getEntryMatchAnyWithIdMock(),
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns field of type "match" with updated field if not a nested entry', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadIFieldType: IFieldType = getField('ip');
|
||||
const output = getEntryOnFieldChange(payloadItem, payloadIFieldType);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnOperatorChange', () => {
|
||||
test('it returns updated subentry preserving its value when entry is not switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'some value',
|
||||
operator: 'excluded',
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry resetting its value when entry is switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isNotOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'some value',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => {
|
||||
const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const payloadOperator: OperatorOption = isOneOfOperator;
|
||||
const output = getEntryOnOperatorChange(payloadItem, payloadOperator);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnMatchChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = getMockBuilderEntry();
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined };
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: '',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = getMockNestedBuilderEntry();
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined };
|
||||
const output = getEntryOnMatchChange(payload, 'jibber jabber');
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: '',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
value: 'jibber jabber',
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnMatchAnyChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: ['some value'],
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: ['some value'],
|
||||
field: undefined,
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: '',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
field: 'nestedField',
|
||||
entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }],
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: 'child',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockNestedBuilderEntry(),
|
||||
field: undefined,
|
||||
parent: {
|
||||
parent: {
|
||||
...getEntryNestedWithIdMock(),
|
||||
field: 'nestedField',
|
||||
entries: [{ ...getEntryMatchAnyWithIdMock(), field: 'child' }],
|
||||
},
|
||||
parentIndex: 0,
|
||||
},
|
||||
};
|
||||
const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']);
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
index: 0,
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
entries: [
|
||||
{
|
||||
id: '123',
|
||||
field: '',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
value: ['jibber jabber'],
|
||||
},
|
||||
],
|
||||
field: 'nestedField',
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
},
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getEntryOnListChange', () => {
|
||||
test('it returns entry with updated value', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: '1234',
|
||||
};
|
||||
const output = getEntryOnListChange(payload, getListResponseMock());
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: 'ip',
|
||||
type: 'list',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => {
|
||||
const payload: FormattedBuilderEntry = {
|
||||
...getMockBuilderEntry(),
|
||||
operator: isOneOfOperator,
|
||||
value: '1234',
|
||||
field: undefined,
|
||||
};
|
||||
const output = getEntryOnListChange(payload, getListResponseMock());
|
||||
const expected: { updatedEntry: BuilderEntry & { id?: string }; index: number } = {
|
||||
updatedEntry: {
|
||||
id: '123',
|
||||
field: '',
|
||||
type: 'list',
|
||||
list: { id: 'some-list-id', type: 'ip' },
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
},
|
||||
index: 0,
|
||||
};
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,91 +7,34 @@
|
|||
|
||||
import uuid from 'uuid';
|
||||
|
||||
import { addIdToItem } from '../../../../../common/add_remove_id_to_item';
|
||||
import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common';
|
||||
import {
|
||||
Entry,
|
||||
OperatorTypeEnum,
|
||||
EntryNested,
|
||||
ExceptionListType,
|
||||
entriesList,
|
||||
ListSchema,
|
||||
OperatorEnum,
|
||||
} from '../../../../lists_plugin_deps';
|
||||
import { isOperator } from '../../autocomplete/operators';
|
||||
import {
|
||||
isOperator,
|
||||
existsOperator,
|
||||
isOneOfOperator,
|
||||
EXCEPTION_OPERATORS,
|
||||
EXCEPTION_OPERATORS_SANS_LISTS,
|
||||
isNotOperator,
|
||||
doesNotExistOperator,
|
||||
} from '../../autocomplete/operators';
|
||||
import { OperatorOption } from '../../autocomplete/types';
|
||||
import {
|
||||
BuilderEntry,
|
||||
FormattedBuilderEntry,
|
||||
ExceptionsBuilderExceptionItem,
|
||||
EmptyEntry,
|
||||
EmptyNestedEntry,
|
||||
BuilderEntry,
|
||||
} from '../types';
|
||||
import { getEntryValue, getExceptionOperatorSelect } from '../helpers';
|
||||
import exceptionableFields from '../exceptionable_fields.json';
|
||||
|
||||
/**
|
||||
* Returns filtered index patterns based on the field - if a user selects to
|
||||
* add nested entry, should only show nested fields, if item is the parent
|
||||
* field of a nested entry, we only display the parent field
|
||||
*
|
||||
* @param patterns IIndexPattern containing available fields on rule index
|
||||
* @param item exception item entry
|
||||
* set to add a nested field
|
||||
*/
|
||||
export const getFilteredIndexPatterns = (
|
||||
export const filterIndexPatterns = (
|
||||
patterns: IIndexPattern,
|
||||
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 {
|
||||
...indexPatterns,
|
||||
fields: indexPatterns.fields
|
||||
.filter((indexField) => {
|
||||
const fieldHasCommonParentPath =
|
||||
indexField.subType != null &&
|
||||
indexField.subType.nested != null &&
|
||||
item.parent != null &&
|
||||
indexField.subType.nested.path === item.parent.parent.field;
|
||||
|
||||
return fieldHasCommonParentPath;
|
||||
})
|
||||
.map((f) => {
|
||||
const fieldNameWithoutParentPath = f.name.split('.').slice(-1)[0];
|
||||
return { ...f, name: fieldNameWithoutParentPath };
|
||||
}),
|
||||
};
|
||||
} else if (item.nested === 'parent' && item.field != null) {
|
||||
// when user has selected a nested entry, right above it we show the common parent
|
||||
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 {
|
||||
...indexPatterns,
|
||||
fields: indexPatterns.fields.filter(
|
||||
(field) => field.subType != null && field.subType.nested != null
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return indexPatterns;
|
||||
}
|
||||
return type === 'endpoint'
|
||||
? {
|
||||
...patterns,
|
||||
fields: patterns.fields.filter(({ name }) => exceptionableFields.includes(name)),
|
||||
}
|
||||
: patterns;
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -310,315 +253,6 @@ export const getUpdatedEntriesOnDelete = (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* On operator change, determines whether value needs to be cleared or not
|
||||
*
|
||||
* @param field
|
||||
* @param selectedOperator
|
||||
* @param currentEntry
|
||||
*
|
||||
*/
|
||||
export const getEntryFromOperator = (
|
||||
selectedOperator: OperatorOption,
|
||||
currentEntry: FormattedBuilderEntry
|
||||
): Entry & { id?: string } => {
|
||||
const isSameOperatorType = currentEntry.operator.type === selectedOperator.type;
|
||||
const fieldValue = currentEntry.field != null ? currentEntry.field.name : '';
|
||||
switch (selectedOperator.type) {
|
||||
case 'match':
|
||||
return {
|
||||
id: currentEntry.id,
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: selectedOperator.operator,
|
||||
value:
|
||||
isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '',
|
||||
};
|
||||
case 'match_any':
|
||||
return {
|
||||
id: currentEntry.id,
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: selectedOperator.operator,
|
||||
value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [],
|
||||
};
|
||||
case 'list':
|
||||
return {
|
||||
id: currentEntry.id,
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: selectedOperator.operator,
|
||||
list: { id: '', type: 'ip' },
|
||||
};
|
||||
default:
|
||||
return {
|
||||
id: currentEntry.id,
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
operator: selectedOperator.operator,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines which operators to make available
|
||||
*
|
||||
* @param item
|
||||
* @param listType
|
||||
* @param isBoolean
|
||||
* @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators
|
||||
*/
|
||||
export const getOperatorOptions = (
|
||||
item: FormattedBuilderEntry,
|
||||
listType: ExceptionListType,
|
||||
isBoolean: boolean,
|
||||
includeValueListOperators = true
|
||||
): OperatorOption[] => {
|
||||
if (item.nested === 'parent' || item.field == null) {
|
||||
return [isOperator];
|
||||
} else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') {
|
||||
return isBoolean ? [isOperator] : [isOperator, isOneOfOperator];
|
||||
} else if (item.nested != null && listType === 'detection') {
|
||||
return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator];
|
||||
} else {
|
||||
return isBoolean
|
||||
? [isOperator, isNotOperator, existsOperator, doesNotExistOperator]
|
||||
: includeValueListOperators
|
||||
? EXCEPTION_OPERATORS
|
||||
: EXCEPTION_OPERATORS_SANS_LISTS;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user selects new field
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly selected field
|
||||
*
|
||||
*/
|
||||
export const getEntryOnFieldChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: IFieldType
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { parent, entryIndex, nested } = item;
|
||||
const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
if (nested === 'parent') {
|
||||
// For nested entries, when user first selects to add a nested
|
||||
// entry, they first see a row similar to what is shown for when
|
||||
// a user selects "exists", as soon as they make a selection
|
||||
// we can now identify the 'parent' and 'child' this is where
|
||||
// we first convert the entry into type "nested"
|
||||
const newParentFieldValue =
|
||||
newField.subType != null && newField.subType.nested != null
|
||||
? newField.subType.nested.path
|
||||
: '';
|
||||
|
||||
return {
|
||||
updatedEntry: {
|
||||
id: item.id,
|
||||
field: newParentFieldValue,
|
||||
type: OperatorTypeEnum.NESTED,
|
||||
entries: [
|
||||
addIdToItem({
|
||||
field: newChildFieldValue ?? '',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: isOperator.operator,
|
||||
value: '',
|
||||
}),
|
||||
],
|
||||
},
|
||||
index: entryIndex,
|
||||
};
|
||||
} else if (nested === 'child' && parent != null) {
|
||||
return {
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
id: item.id,
|
||||
field: newChildFieldValue ?? '',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: isOperator.operator,
|
||||
value: '',
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
index: parent.parentIndex,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
updatedEntry: {
|
||||
id: item.id,
|
||||
field: newField != null ? newField.name : '',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: isOperator.operator,
|
||||
value: '',
|
||||
},
|
||||
index: entryIndex,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user selects new operator
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newOperator - newly selected operator
|
||||
*
|
||||
*/
|
||||
export const getEntryOnOperatorChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newOperator: OperatorOption
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { parent, entryIndex, field, nested } = item;
|
||||
const newEntry = getEntryFromOperator(newOperator, item);
|
||||
|
||||
if (!entriesList.is(newEntry) && nested != null && parent != null) {
|
||||
return {
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
...newEntry,
|
||||
field: field != null ? field.name.split('.').slice(-1)[0] : '',
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
index: parent.parentIndex,
|
||||
};
|
||||
} else {
|
||||
return { updatedEntry: newEntry, index: entryIndex };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "match"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly entered value
|
||||
*
|
||||
*/
|
||||
export const getEntryOnMatchChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: string
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { nested, parent, entryIndex, field, operator } = item;
|
||||
|
||||
if (nested != null && parent != null) {
|
||||
const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
return {
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
id: item.id,
|
||||
field: fieldName,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: operator.operator,
|
||||
value: newField,
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
index: parent.parentIndex,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
updatedEntry: {
|
||||
id: item.id,
|
||||
field: field != null ? field.name : '',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: operator.operator,
|
||||
value: newField,
|
||||
},
|
||||
index: entryIndex,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "match_any"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly entered value
|
||||
*
|
||||
*/
|
||||
export const getEntryOnMatchAnyChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: string[]
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { nested, parent, entryIndex, field, operator } = item;
|
||||
|
||||
if (nested != null && parent != null) {
|
||||
const fieldName = field != null ? field.name.split('.').slice(-1)[0] : '';
|
||||
|
||||
return {
|
||||
updatedEntry: {
|
||||
...parent.parent,
|
||||
entries: [
|
||||
...parent.parent.entries.slice(0, entryIndex),
|
||||
{
|
||||
id: item.id,
|
||||
field: fieldName,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: operator.operator,
|
||||
value: newField,
|
||||
},
|
||||
...parent.parent.entries.slice(entryIndex + 1),
|
||||
],
|
||||
},
|
||||
index: parent.parentIndex,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
updatedEntry: {
|
||||
id: item.id,
|
||||
field: field != null ? field.name : '',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: operator.operator,
|
||||
value: newField,
|
||||
},
|
||||
index: entryIndex,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines proper entry update when user updates value
|
||||
* when operator is of type "list"
|
||||
*
|
||||
* @param item - current exception item entry values
|
||||
* @param newField - newly selected list
|
||||
*
|
||||
*/
|
||||
export const getEntryOnListChange = (
|
||||
item: FormattedBuilderEntry,
|
||||
newField: ListSchema
|
||||
): { updatedEntry: BuilderEntry; index: number } => {
|
||||
const { entryIndex, field, operator } = item;
|
||||
const { id, type } = newField;
|
||||
|
||||
return {
|
||||
updatedEntry: {
|
||||
id: item.id,
|
||||
field: field != null ? field.name : '',
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: operator.operator,
|
||||
list: { id, type },
|
||||
},
|
||||
index: entryIndex,
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultEmptyEntry = (): EmptyEntry => ({
|
||||
id: uuid.v4(),
|
||||
field: '',
|
||||
|
|
|
@ -376,7 +376,6 @@ export const ExceptionBuilderComponent = ({
|
|||
<BuilderExceptionListItemComponent
|
||||
key={getExceptionListItemId(exceptionListItem, index)}
|
||||
exceptionItem={exceptionListItem}
|
||||
exceptionId={getExceptionListItemId(exceptionListItem, index)}
|
||||
indexPattern={indexPatterns}
|
||||
listType={listType}
|
||||
exceptionItemIndex={index}
|
||||
|
|
|
@ -58,4 +58,5 @@ export {
|
|||
UseExceptionListItemsSuccess,
|
||||
addEndpointExceptionList,
|
||||
withOptionalSignal,
|
||||
BuilderEntryItem,
|
||||
} from '../../lists/public';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue