[SIEM][Exceptions] - Exception builder component (#67013)
### Summary This PR creates the bulk functionality of the exception builder. The exception builder is the component that will be used to create exception list items. It does not deal with the actual API creation/deletion/update of exceptions, it does contain an `onChange` handler that can be used to access the exceptions. The builder is able to: - accept `ExceptionListItem` and render them correctly - allow user to add exception list item and exception list item entries - accept an `indexPattern` and use it to fetch relevant field and autocomplete field values - disable `Or` button if user is only allowed to edit/add to exception list item (not add additional exception list items) - displays `Add new exception` button if no exception items exist - An exception item can be created without entries, the `add new exception` button will show in the case that an exception list contains exception list item(s) with an empty `entries` array (as long as there is one exception list item with an item in `entries`, button does not show) - debounces field value autocomplete searches - bubble up exceptions to parent component, stripping out any empty entries
This commit is contained in:
parent
4f7da59a51
commit
6581450449
48 changed files with 3623 additions and 980 deletions
|
@ -9,7 +9,7 @@ import { left } from 'fp-ts/lib/Either';
|
|||
|
||||
import { foldLeftRight, getPaths } from '../../siem_common_deps';
|
||||
|
||||
import { operator_type as operatorType } from './schemas';
|
||||
import { operator, operator_type as operatorType } from './schemas';
|
||||
|
||||
describe('Common schemas', () => {
|
||||
describe('operatorType', () => {
|
||||
|
@ -60,4 +60,35 @@ describe('Common schemas', () => {
|
|||
expect(keys.length).toEqual(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('operator', () => {
|
||||
test('it should validate for "included"', () => {
|
||||
const payload = 'included';
|
||||
const decoded = operator.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
|
||||
test('it should validate for "excluded"', () => {
|
||||
const payload = 'excluded';
|
||||
const decoded = operator.decode(payload);
|
||||
const message = pipe(decoded, foldLeftRight);
|
||||
|
||||
expect(getPaths(left(message.errors))).toEqual([]);
|
||||
expect(message.schema).toEqual(payload);
|
||||
});
|
||||
|
||||
test('it should contain 2 keys', () => {
|
||||
// Might seem like a weird test, but its meant to
|
||||
// ensure that if operator is updated, you
|
||||
// also update the operatorEnum, a workaround
|
||||
// for io-ts not yet supporting enums
|
||||
// https://github.com/gcanti/io-ts/issues/67
|
||||
const keys = Object.keys(operator.keys);
|
||||
|
||||
expect(keys.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -130,6 +130,10 @@ export type NamespaceType = t.TypeOf<typeof namespace_type>;
|
|||
|
||||
export const operator = t.keyof({ excluded: null, included: null });
|
||||
export type Operator = t.TypeOf<typeof operator>;
|
||||
export enum OperatorEnum {
|
||||
INCLUDED = 'included',
|
||||
EXCLUDED = 'excluded',
|
||||
}
|
||||
|
||||
export const operator_type = t.keyof({
|
||||
exists: null,
|
||||
|
|
|
@ -15,7 +15,6 @@ const antennaStyles = css`
|
|||
background: ${({ theme }) => theme.eui.euiColorLightShade};
|
||||
position: relative;
|
||||
width: 2px;
|
||||
margin: 0 12px 0 0;
|
||||
&:after {
|
||||
background: ${({ theme }) => theme.eui.euiColorLightShade};
|
||||
content: '';
|
||||
|
@ -40,10 +39,6 @@ const BottomAntenna = styled(EuiFlexItem)`
|
|||
}
|
||||
`;
|
||||
|
||||
const EuiFlexItemWrapper = styled(EuiFlexItem)`
|
||||
margin: 0 12px 0 0;
|
||||
`;
|
||||
|
||||
export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => (
|
||||
<EuiFlexGroup
|
||||
className="andBadgeContainer"
|
||||
|
@ -52,9 +47,9 @@ export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => (
|
|||
alignItems="center"
|
||||
>
|
||||
<TopAntenna data-test-subj="andOrBadgeBarTop" grow={1} />
|
||||
<EuiFlexItemWrapper grow={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<RoundedBadge type={type} />
|
||||
</EuiFlexItemWrapper>
|
||||
</EuiFlexItem>
|
||||
<BottomAntenna data-test-subj="andOrBadgeBarBottom" grow={1} />
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { FieldComponent } from './field';
|
||||
|
||||
describe('FieldComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<FieldComponent
|
||||
placeholder="Placeholder text"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<FieldComponent
|
||||
placeholder="Placeholder text"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
isLoading={true}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<FieldComponent
|
||||
placeholder="Placeholder text"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
isLoading={false}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected field', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<FieldComponent
|
||||
placeholder="Placeholder text"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<FieldComponent
|
||||
placeholder="Placeholder text"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
selectedField={getField('machine.os.raw')}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((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,74 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { getGenericComboBoxProps } from './helpers';
|
||||
import { GetGenericComboBoxPropsReturn } from './types';
|
||||
|
||||
interface OperatorProps {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
fieldInputWidth?: number;
|
||||
onChange: (a: IFieldType[]) => void;
|
||||
}
|
||||
|
||||
export const FieldComponent: React.FC<OperatorProps> = ({
|
||||
placeholder,
|
||||
selectedField,
|
||||
indexPattern,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
fieldInputWidth = 190,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const getLabel = useCallback((field): string => field.name, []);
|
||||
const optionsMemo = useMemo((): IFieldType[] => (indexPattern ? indexPattern.fields : []), [
|
||||
indexPattern,
|
||||
]);
|
||||
const selectedOptionsMemo = useMemo((): IFieldType[] => (selectedField ? [selectedField] : []), [
|
||||
selectedField,
|
||||
]);
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<IFieldType>({
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
getLabel,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: IFieldType[] = newOptions.map(
|
||||
({ label }) => optionsMemo[labels.indexOf(label)]
|
||||
);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
isClearable={isClearable}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
data-test-subj="fieldAutocompleteComboBox"
|
||||
style={{ width: `${fieldInputWidth}px` }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
FieldComponent.displayName = 'Field';
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
|
||||
import { AutocompleteFieldExistsComponent } from './field_value_exists';
|
||||
|
||||
describe('AutocompleteFieldExistsComponent', () => {
|
||||
test('it renders field disabled', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldExistsComponent placeholder="Placeholder text" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox existsComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiComboBox } from '@elastic/eui';
|
||||
|
||||
interface AutocompleteFieldExistsProps {
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldExistsComponent: React.FC<AutocompleteFieldExistsProps> = ({
|
||||
placeholder,
|
||||
}): JSX.Element => (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={[]}
|
||||
selectedOptions={[]}
|
||||
onChange={undefined}
|
||||
isDisabled
|
||||
data-test-subj="valuesAutocompleteComboBox existsComboxBox"
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
|
||||
AutocompleteFieldExistsComponent.displayName = 'AutocompleteFieldExists';
|
|
@ -0,0 +1,161 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { AutocompleteFieldListsComponent } from './field_value_lists';
|
||||
import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock';
|
||||
|
||||
const mockStart = jest.fn();
|
||||
const mockResult = getFoundListSchemaMock();
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
jest.mock('../../../lists_plugin_deps', () => {
|
||||
const originalModule = jest.requireActual('../../../lists_plugin_deps');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
useFindLists: () => ({
|
||||
loading: false,
|
||||
start: mockStart.mockReturnValue(mockResult),
|
||||
result: mockResult,
|
||||
error: undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('AutocompleteFieldListsComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldListsComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldListsComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
isLoading={true}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`)
|
||||
.at(0)
|
||||
.simulate('click');
|
||||
expect(
|
||||
wrapper
|
||||
.find(
|
||||
`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]`
|
||||
)
|
||||
.prop('isLoading')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it allows user to clear values if "isClearable" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldListsComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
isLoading={false}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected list', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldListsComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="some-list-id"
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldListsComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'some name' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
created_at: '2020-04-20T15:25:31.830Z',
|
||||
created_by: 'some user',
|
||||
description: 'some description',
|
||||
id: 'some-list-id',
|
||||
meta: {},
|
||||
name: 'some name',
|
||||
tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e',
|
||||
type: 'ip',
|
||||
updated_at: '2020-04-20T15:25:31.830Z',
|
||||
updated_by: 'some user',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
|
||||
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { useFindLists, ListSchema } from '../../../lists_plugin_deps';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { getGenericComboBoxProps } from './helpers';
|
||||
|
||||
interface AutocompleteFieldListsProps {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
selectedValue: string | undefined;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
onChange: (arg: ListSchema) => void;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldListsComponent: React.FC<AutocompleteFieldListsProps> = ({
|
||||
placeholder,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const { http } = useKibana().services;
|
||||
const [lists, setLists] = useState<ListSchema[]>([]);
|
||||
const { loading, result, start } = useFindLists();
|
||||
const getLabel = useCallback(({ name }) => name, []);
|
||||
|
||||
const optionsMemo = useMemo(() => {
|
||||
if (selectedField != null) {
|
||||
return lists.filter(({ type }) => type === selectedField.type);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [lists, selectedField]);
|
||||
const selectedOptionsMemo = useMemo(() => {
|
||||
if (selectedValue != null) {
|
||||
const list = lists.filter(({ id }) => id === selectedValue);
|
||||
return list ?? [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}, [selectedValue, lists]);
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
() =>
|
||||
getGenericComboBoxProps<ListSchema>({
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
getLabel,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = useCallback(
|
||||
(newOptions: EuiComboBoxOptionOption[]) => {
|
||||
const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
onChange(newValue ?? '');
|
||||
},
|
||||
[labels, optionsMemo, onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result != null) {
|
||||
setLists(result.data);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedField != null) {
|
||||
start({
|
||||
http,
|
||||
pageIndex: 1,
|
||||
pageSize: 500,
|
||||
});
|
||||
}
|
||||
}, [selectedField, start, http]);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled}
|
||||
isLoading={isLoading || loading}
|
||||
isClearable={isClearable}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
sortMatchesBy="startsWith"
|
||||
data-test-subj="valuesAutocompleteComboBox listsComboxBox"
|
||||
fullWidth
|
||||
async
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AutocompleteFieldListsComponent.displayName = 'AutocompleteFieldList';
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { AutocompleteFieldMatchComponent } from './field_value_match';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
jest.mock('./hooks/use_field_value_autocomplete');
|
||||
|
||||
describe('AutocompleteFieldMatchComponent', () => {
|
||||
const getValueSuggestionsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue([false, ['value 3', 'value 4'], jest.fn()]);
|
||||
|
||||
beforeAll(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
['value 1', 'value 2'],
|
||||
getValueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={true}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] button`)
|
||||
.at(0)
|
||||
.simulate('click');
|
||||
expect(
|
||||
wrapper
|
||||
.find(
|
||||
`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox matchComboxBox-optionsList"]`
|
||||
)
|
||||
.prop('isLoading')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it allows user to clear values if "isClearable" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected value', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue="126.45.211.34"
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchComboxBox"] EuiComboBoxPill`)
|
||||
.at(0)
|
||||
.text()
|
||||
).toEqual('126.45.211.34');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value created', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue=""
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onCreateOption: (a: string) => void;
|
||||
}).onCreateOption('126.45.211.34');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('126.45.211.34');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value selected', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue=""
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'value 1' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('value 1');
|
||||
});
|
||||
|
||||
test('it invokes updateSuggestions when new value searched', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue=""
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onSearchChange: (a: string) => void;
|
||||
}).onSearchChange('value 1');
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledWith({
|
||||
fieldSelected: getField('machine.os.raw'),
|
||||
patterns: {
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
},
|
||||
value: 'value 1',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
import { validateParams, getGenericComboBoxProps } from './helpers';
|
||||
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
import { GetGenericComboBoxPropsReturn } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface AutocompleteFieldMatchProps {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
selectedValue: string | undefined;
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
onChange: (arg: string) => void;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldMatchComponent: React.FC<AutocompleteFieldMatchProps> = ({
|
||||
placeholder,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({
|
||||
selectedField,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
fieldValue: selectedValue,
|
||||
indexPattern,
|
||||
});
|
||||
const getLabel = useCallback((option: string): string => option, []);
|
||||
const optionsMemo = useMemo((): string[] => {
|
||||
const valueAsStr = String(selectedValue);
|
||||
return selectedValue ? uniq([valueAsStr, ...suggestions]) : suggestions;
|
||||
}, [suggestions, selectedValue]);
|
||||
const selectedOptionsMemo = useMemo((): string[] => {
|
||||
const valueAsStr = String(selectedValue);
|
||||
return selectedValue ? [valueAsStr] : [];
|
||||
}, [selectedValue]);
|
||||
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<string>({
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
getLabel,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
onChange(newValue ?? '');
|
||||
};
|
||||
|
||||
const onSearchChange = (searchVal: string): void => {
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
updateSuggestions({
|
||||
fieldSelected: selectedField,
|
||||
value: `${searchVal}`,
|
||||
patterns: indexPattern,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid = useMemo(
|
||||
(): boolean => validateParams(selectedValue, selectedField ? selectedField.type : ''),
|
||||
[selectedField, selectedValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder}
|
||||
isDisabled={isDisabled}
|
||||
isLoading={isLoading || isLoadingSuggestions}
|
||||
isClearable={isClearable}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
onSearchChange={onSearchChange}
|
||||
onCreateOption={onChange}
|
||||
isInvalid={!isValid}
|
||||
sortMatchesBy="startsWith"
|
||||
data-test-subj="valuesAutocompleteComboBox matchComboxBox"
|
||||
fullWidth
|
||||
async
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch';
|
|
@ -0,0 +1,238 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
fields,
|
||||
getField,
|
||||
} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
jest.mock('./hooks/use_field_value_autocomplete');
|
||||
|
||||
describe('AutocompleteFieldMatchAnyComponent', () => {
|
||||
const getValueSuggestionsMock = jest
|
||||
.fn()
|
||||
.mockResolvedValue([false, ['value 3', 'value 4'], jest.fn()]);
|
||||
|
||||
beforeAll(() => {
|
||||
(useFieldValueAutocomplete as jest.Mock).mockReturnValue([
|
||||
false,
|
||||
['value 1', 'value 2'],
|
||||
getValueSuggestionsMock,
|
||||
]);
|
||||
});
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={true}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] input`)
|
||||
.prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={[]}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={true}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] button`)
|
||||
.at(0)
|
||||
.simulate('click');
|
||||
expect(
|
||||
wrapper
|
||||
.find(
|
||||
`EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox-optionsList"]`
|
||||
)
|
||||
.prop('isLoading')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it allows user to clear values if "isClearable" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={true}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="comboBoxInput"]`)
|
||||
.hasClass('euiComboBox__inputWrap-isClearable')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it correctly displays selected value', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={['126.45.211.34']}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find(`[data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"] EuiComboBoxPill`)
|
||||
.at(0)
|
||||
.text()
|
||||
).toEqual('126.45.211.34');
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value created', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ip')}
|
||||
selectedValue={[]}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onCreateOption: (a: string) => void;
|
||||
}).onCreateOption('126.45.211.34');
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['126.45.211.34']);
|
||||
});
|
||||
|
||||
test('it invokes "onChange" when new value selected', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue={[]}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onChange: (a: EuiComboBoxOptionOption[]) => void;
|
||||
}).onChange([{ label: 'value 1' }]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['value 1']);
|
||||
});
|
||||
|
||||
test('it invokes updateSuggestions when new value searched', async () => {
|
||||
const mockOnChange = jest.fn();
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
selectedValue={[]}
|
||||
indexPattern={{
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
}}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
isDisabled={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((wrapper.find(EuiComboBox).props() as unknown) as {
|
||||
onSearchChange: (a: string) => void;
|
||||
}).onSearchChange('value 1');
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledWith({
|
||||
fieldSelected: getField('machine.os.raw'),
|
||||
patterns: {
|
||||
id: '1234',
|
||||
title: 'logstash-*',
|
||||
fields,
|
||||
},
|
||||
value: 'value 1',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
|
||||
import { uniq } from 'lodash';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete';
|
||||
import { getGenericComboBoxProps, validateParams } from './helpers';
|
||||
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
import { GetGenericComboBoxPropsReturn } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface AutocompleteFieldMatchAnyProps {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
selectedValue: string[];
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
onChange: (arg: string[]) => void;
|
||||
}
|
||||
|
||||
export const AutocompleteFieldMatchAnyComponent: React.FC<AutocompleteFieldMatchAnyProps> = ({
|
||||
placeholder,
|
||||
selectedField,
|
||||
selectedValue,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({
|
||||
selectedField,
|
||||
operatorType: OperatorTypeEnum.MATCH_ANY,
|
||||
fieldValue: selectedValue,
|
||||
indexPattern,
|
||||
});
|
||||
const getLabel = useCallback((option: string): string => option, []);
|
||||
const optionsMemo = useMemo(
|
||||
(): string[] => (selectedValue ? uniq([...selectedValue, ...suggestions]) : suggestions),
|
||||
[suggestions, selectedValue]
|
||||
);
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<string>({
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedValue,
|
||||
getLabel,
|
||||
}),
|
||||
[optionsMemo, selectedValue, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: string[] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
const onSearchChange = (searchVal: string) => {
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
updateSuggestions({
|
||||
fieldSelected: selectedField,
|
||||
value: `${searchVal}`,
|
||||
patterns: indexPattern,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateOption = (option: string) => onChange([...(selectedValue || []), option]);
|
||||
|
||||
const isValid = useMemo((): boolean => {
|
||||
const areAnyInvalid = selectedComboOptions.filter(
|
||||
({ label }) => !validateParams(label, selectedField ? selectedField.type : '')
|
||||
);
|
||||
return areAnyInvalid.length === 0;
|
||||
}, [selectedComboOptions, selectedField]);
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder}
|
||||
isLoading={isLoading || isLoadingSuggestions}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
onSearchChange={onSearchChange}
|
||||
onCreateOption={onCreateOption}
|
||||
isInvalid={!isValid}
|
||||
delimiter=", "
|
||||
data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox"
|
||||
fullWidth
|
||||
async
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
AutocompleteFieldMatchAnyComponent.displayName = 'AutocompleteFieldMatchAny';
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
} from './operators';
|
||||
import { getOperators, validateParams, getGenericComboBoxProps } from './helpers';
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('#getOperators', () => {
|
||||
test('it returns "isOperator" if passed in field is "undefined"', () => {
|
||||
const operator = getOperators(undefined);
|
||||
|
||||
expect(operator).toEqual([isOperator]);
|
||||
});
|
||||
|
||||
test('it returns expected operators when field type is "boolean"', () => {
|
||||
const operator = getOperators(getField('ssl'));
|
||||
|
||||
expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]);
|
||||
});
|
||||
|
||||
test('it returns "isOperator" when field type is "nested"', () => {
|
||||
const operator = getOperators({
|
||||
name: 'nestedField',
|
||||
type: 'nested',
|
||||
esTypes: ['text'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: false,
|
||||
readFromDocValues: false,
|
||||
subType: { nested: { path: 'nestedField' } },
|
||||
});
|
||||
|
||||
expect(operator).toEqual([isOperator]);
|
||||
});
|
||||
|
||||
test('it returns all operator types when field type is not null, boolean, or nested', () => {
|
||||
const operator = getOperators(getField('machine.os.raw'));
|
||||
|
||||
expect(operator).toEqual(EXCEPTION_OPERATORS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#validateParams', () => {
|
||||
test('returns true if value is undefined', () => {
|
||||
const isValid = validateParams(undefined, 'date');
|
||||
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns true if value is empty string', () => {
|
||||
const isValid = validateParams('', 'date');
|
||||
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns true if type is "date" and value is valid', () => {
|
||||
const isValid = validateParams('1994-11-05T08:15:30-05:00', 'date');
|
||||
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns false if type is "date" and value is not valid', () => {
|
||||
const isValid = validateParams('1593478826', 'date');
|
||||
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
test('returns true if type is "ip" and value is valid', () => {
|
||||
const isValid = validateParams('126.45.211.34', 'ip');
|
||||
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns false if type is "ip" and value is not valid', () => {
|
||||
const isValid = validateParams('hellooo', 'ip');
|
||||
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
|
||||
test('returns true if type is "number" and value is valid', () => {
|
||||
const isValid = validateParams('123', 'number');
|
||||
|
||||
expect(isValid).toBeTruthy();
|
||||
});
|
||||
|
||||
test('returns false if type is "number" and value is not valid', () => {
|
||||
const isValid = validateParams('not a number', 'number');
|
||||
|
||||
expect(isValid).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getGenericComboBoxProps', () => {
|
||||
test('it returns empty arrays if "options" is empty array', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
options: [],
|
||||
selectedOptions: ['option1'],
|
||||
getLabel: (t: string) => t,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] });
|
||||
});
|
||||
|
||||
test('it returns formatted props if "options" array is not empty', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: [],
|
||||
getLabel: (t: string) => t,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
comboOptions: [
|
||||
{
|
||||
label: 'option1',
|
||||
},
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
{
|
||||
label: 'option3',
|
||||
},
|
||||
],
|
||||
labels: ['option1', 'option2', 'option3'],
|
||||
selectedComboOptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('it does not return "selectedOptions" items that do not appear in "options"', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: ['option4'],
|
||||
getLabel: (t: string) => t,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
comboOptions: [
|
||||
{
|
||||
label: 'option1',
|
||||
},
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
{
|
||||
label: 'option3',
|
||||
},
|
||||
],
|
||||
labels: ['option1', 'option2', 'option3'],
|
||||
selectedComboOptions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('it return "selectedOptions" items that do appear in "options"', () => {
|
||||
const result = getGenericComboBoxProps<string>({
|
||||
options: ['option1', 'option2', 'option3'],
|
||||
selectedOptions: ['option2'],
|
||||
getLabel: (t: string) => t,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
comboOptions: [
|
||||
{
|
||||
label: 'option1',
|
||||
},
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
{
|
||||
label: 'option3',
|
||||
},
|
||||
],
|
||||
labels: ['option1', 'option2', 'option3'],
|
||||
selectedComboOptions: [
|
||||
{
|
||||
label: 'option2',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import dateMath from '@elastic/datemath';
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { Ipv4Address } from '../../../../../../../src/plugins/kibana_utils/public';
|
||||
import {
|
||||
EXCEPTION_OPERATORS,
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
} from './operators';
|
||||
import { GetGenericComboBoxPropsReturn, OperatorOption } from './types';
|
||||
|
||||
export const getOperators = (field: IFieldType | undefined): OperatorOption[] => {
|
||||
if (field == null) {
|
||||
return [isOperator];
|
||||
} else if (field.type === 'boolean') {
|
||||
return [isOperator, isNotOperator, existsOperator, doesNotExistOperator];
|
||||
} else if (field.type === 'nested') {
|
||||
return [isOperator];
|
||||
} else {
|
||||
return EXCEPTION_OPERATORS;
|
||||
}
|
||||
};
|
||||
|
||||
export function validateParams(params: string | undefined, type: string) {
|
||||
// Box would show error state if empty otherwise
|
||||
if (params == null || params === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'date':
|
||||
const moment = dateMath.parse(params);
|
||||
return Boolean(moment && moment.isValid());
|
||||
case 'ip':
|
||||
try {
|
||||
return Boolean(new Ipv4Address(params));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
case 'number':
|
||||
const val = parseFloat(params);
|
||||
return typeof val === 'number' && !isNaN(val);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function getGenericComboBoxProps<T>({
|
||||
options,
|
||||
selectedOptions,
|
||||
getLabel,
|
||||
}: {
|
||||
options: T[];
|
||||
selectedOptions: T[];
|
||||
getLabel: (value: T) => string;
|
||||
}): GetGenericComboBoxPropsReturn {
|
||||
const newLabels = options.map(getLabel);
|
||||
const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label }));
|
||||
const newSelectedComboOptions = selectedOptions
|
||||
.filter((option) => {
|
||||
return options.indexOf(option) !== -1;
|
||||
})
|
||||
.map((option) => {
|
||||
return newComboOptions[options.indexOf(option)];
|
||||
});
|
||||
|
||||
return {
|
||||
comboOptions: newComboOptions,
|
||||
labels: newLabels,
|
||||
selectedComboOptions: newSelectedComboOptions,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import {
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn,
|
||||
useFieldValueAutocomplete,
|
||||
} from './use_field_value_autocomplete';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub';
|
||||
import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { OperatorTypeEnum } from '../../../../lists_plugin_deps';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
describe('useFieldValueAutocomplete', () => {
|
||||
const onErrorMock = jest.fn();
|
||||
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']);
|
||||
|
||||
beforeAll(() => {
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
data: {
|
||||
autocomplete: {
|
||||
getValueSuggestions: getValueSuggestionsMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onErrorMock.mockClear();
|
||||
getValueSuggestionsMock.mockClear();
|
||||
});
|
||||
|
||||
test('initializes hook', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: undefined,
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
fieldValue: '',
|
||||
indexPattern: undefined,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current).toEqual([false, [], result.current[2]]);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call autocomplete service if "operatorType" is "exists"', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: getField('machine.os'),
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call autocomplete service if "selectedField" is undefined', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: undefined,
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call autocomplete service if "indexPattern" is undefined', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: getField('machine.os'),
|
||||
operatorType: OperatorTypeEnum.EXISTS,
|
||||
fieldValue: '',
|
||||
indexPattern: undefined,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [false, [], result.current[2]];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns suggestions of "true" and "false" if field type is boolean', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: getField('ssl'),
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [
|
||||
false,
|
||||
['true', 'false'],
|
||||
result.current[2],
|
||||
];
|
||||
|
||||
expect(getValueSuggestionsMock).not.toHaveBeenCalled();
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns suggestions', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: getField('@tags'),
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [
|
||||
false,
|
||||
['value 1', 'value 2'],
|
||||
result.current[2],
|
||||
];
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledWith({
|
||||
field: getField('@tags'),
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
query: '',
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
|
||||
test('returns new suggestions on subsequent calls', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<
|
||||
UseFieldValueAutocompleteProps,
|
||||
UseFieldValueAutocompleteReturn
|
||||
>(() =>
|
||||
useFieldValueAutocomplete({
|
||||
selectedField: getField('@tags'),
|
||||
operatorType: OperatorTypeEnum.MATCH,
|
||||
fieldValue: '',
|
||||
indexPattern: stubIndexPatternWithFields,
|
||||
})
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current[2]({
|
||||
fieldSelected: getField('@tags'),
|
||||
value: 'hello',
|
||||
patterns: stubIndexPatternWithFields,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const expectedResult: UseFieldValueAutocompleteReturn = [
|
||||
false,
|
||||
['value 1', 'value 2'],
|
||||
result.current[2],
|
||||
];
|
||||
|
||||
expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2);
|
||||
expect(result.current).toEqual(expectedResult);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { useKibana } from '../../../../common/lib/kibana';
|
||||
import { OperatorTypeEnum } from '../../../../lists_plugin_deps';
|
||||
|
||||
export type UseFieldValueAutocompleteReturn = [
|
||||
boolean,
|
||||
string[],
|
||||
(args: {
|
||||
fieldSelected: IFieldType | undefined;
|
||||
value: string | string[] | undefined;
|
||||
patterns: IIndexPattern | undefined;
|
||||
signal: AbortSignal;
|
||||
}) => void
|
||||
];
|
||||
|
||||
export interface UseFieldValueAutocompleteProps {
|
||||
selectedField: IFieldType | undefined;
|
||||
operatorType: OperatorTypeEnum;
|
||||
fieldValue: string | string[] | undefined;
|
||||
indexPattern: IIndexPattern | undefined;
|
||||
}
|
||||
/**
|
||||
* Hook for using the field value autocomplete service
|
||||
*
|
||||
*/
|
||||
export const useFieldValueAutocomplete = ({
|
||||
selectedField,
|
||||
operatorType,
|
||||
fieldValue,
|
||||
indexPattern,
|
||||
}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => {
|
||||
const { services } = useKibana();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const updateSuggestions = useRef(
|
||||
debounce(
|
||||
async ({
|
||||
fieldSelected,
|
||||
value,
|
||||
patterns,
|
||||
signal,
|
||||
}: {
|
||||
fieldSelected: IFieldType | undefined;
|
||||
value: string | string[] | undefined;
|
||||
patterns: IIndexPattern | undefined;
|
||||
signal: AbortSignal;
|
||||
}) => {
|
||||
if (fieldSelected == null || patterns == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Fields of type boolean should only display two options
|
||||
if (fieldSelected.type === 'boolean') {
|
||||
setIsLoading(false);
|
||||
setSuggestions(['true', 'false']);
|
||||
return;
|
||||
}
|
||||
|
||||
const newSuggestions = await services.data.autocomplete.getValueSuggestions({
|
||||
indexPattern: patterns,
|
||||
field: fieldSelected,
|
||||
query: '',
|
||||
signal,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setSuggestions(newSuggestions);
|
||||
},
|
||||
500
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
if (operatorType !== OperatorTypeEnum.EXISTS) {
|
||||
updateSuggestions.current({
|
||||
fieldSelected: selectedField,
|
||||
value: fieldValue,
|
||||
patterns: indexPattern,
|
||||
signal: abortCtrl.signal,
|
||||
});
|
||||
}
|
||||
|
||||
return (): void => {
|
||||
abortCtrl.abort();
|
||||
};
|
||||
}, [updateSuggestions, selectedField, operatorType, fieldValue, indexPattern]);
|
||||
|
||||
return [isLoading, suggestions, updateSuggestions.current];
|
||||
};
|
|
@ -0,0 +1,197 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import { mount } from 'enzyme';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts';
|
||||
import { OperatorComponent } from './operator';
|
||||
import { isOperator, isNotOperator } from './operators';
|
||||
|
||||
describe('OperatorComponent', () => {
|
||||
test('it renders disabled if "isDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={true}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"] input`).prop('disabled')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it renders loading if "isLoading" is true', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={true}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={true}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it displays "operatorOptions" if param is passed in', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
operatorOptions={[isNotOperator]}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options')
|
||||
).toEqual([{ label: 'is not' }]);
|
||||
});
|
||||
|
||||
test('it correctly displays selected operator', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={{
|
||||
name: 'nestedField',
|
||||
type: 'nested',
|
||||
esTypes: ['text'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: false,
|
||||
readFromDocValues: false,
|
||||
subType: { nested: { path: 'nestedField' } },
|
||||
}}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('ssl')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={jest.fn()}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
<OperatorComponent
|
||||
placeholder="Placeholder text"
|
||||
selectedField={getField('machine.os.raw')}
|
||||
operator={isOperator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
((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,77 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui';
|
||||
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { getOperators, getGenericComboBoxProps } from './helpers';
|
||||
import { GetGenericComboBoxPropsReturn, OperatorOption } from './types';
|
||||
|
||||
interface OperatorState {
|
||||
placeholder: string;
|
||||
selectedField: IFieldType | undefined;
|
||||
operator: OperatorOption;
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isClearable: boolean;
|
||||
operatorInputWidth?: number;
|
||||
operatorOptions?: OperatorOption[];
|
||||
onChange: (arg: OperatorOption[]) => void;
|
||||
}
|
||||
|
||||
export const OperatorComponent: React.FC<OperatorState> = ({
|
||||
placeholder,
|
||||
selectedField,
|
||||
operator,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
isClearable = false,
|
||||
operatorOptions,
|
||||
operatorInputWidth = 150,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const getLabel = useCallback(({ message }): string => message, []);
|
||||
const optionsMemo = useMemo(
|
||||
(): OperatorOption[] => (operatorOptions ? operatorOptions : getOperators(selectedField)),
|
||||
[operatorOptions, selectedField]
|
||||
);
|
||||
const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [
|
||||
operator,
|
||||
]);
|
||||
const { comboOptions, labels, selectedComboOptions } = useMemo(
|
||||
(): GetGenericComboBoxPropsReturn =>
|
||||
getGenericComboBoxProps<OperatorOption>({
|
||||
options: optionsMemo,
|
||||
selectedOptions: selectedOptionsMemo,
|
||||
getLabel,
|
||||
}),
|
||||
[optionsMemo, selectedOptionsMemo, getLabel]
|
||||
);
|
||||
|
||||
const handleValuesChange = (newOptions: EuiComboBoxOptionOption[]): void => {
|
||||
const newValues: OperatorOption[] = newOptions.map(
|
||||
({ label }) => optionsMemo[labels.indexOf(label)]
|
||||
);
|
||||
onChange(newValues);
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiComboBox
|
||||
placeholder={placeholder}
|
||||
options={comboOptions}
|
||||
selectedOptions={selectedComboOptions}
|
||||
onChange={handleValuesChange}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
isClearable={isClearable}
|
||||
singleSelection={{ asPlainText: true }}
|
||||
data-test-subj="operatorAutocompleteComboBox"
|
||||
style={{ width: `${operatorInputWidth}px` }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
OperatorComponent.displayName = 'Operator';
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { OperatorOption } from './types';
|
||||
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
import { OperatorEnum, OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
|
||||
export const isOperator: OperatorOption = {
|
||||
message: i18n.translate('xpack.securitySolution.exceptions.isOperatorLabel', {
|
||||
|
@ -14,7 +14,7 @@ export const isOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: 'included',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
};
|
||||
|
||||
export const isNotOperator: OperatorOption = {
|
||||
|
@ -23,7 +23,7 @@ export const isNotOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is_not',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: 'excluded',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
};
|
||||
|
||||
export const isOneOfOperator: OperatorOption = {
|
||||
|
@ -32,7 +32,7 @@ export const isOneOfOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is_one_of',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: 'included',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
};
|
||||
|
||||
export const isNotOneOfOperator: OperatorOption = {
|
||||
|
@ -41,7 +41,7 @@ export const isNotOneOfOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is_not_one_of',
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: 'excluded',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
};
|
||||
|
||||
export const existsOperator: OperatorOption = {
|
||||
|
@ -50,7 +50,7 @@ export const existsOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'exists',
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
operator: 'included',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
};
|
||||
|
||||
export const doesNotExistOperator: OperatorOption = {
|
||||
|
@ -59,7 +59,7 @@ export const doesNotExistOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'does_not_exist',
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
operator: 'excluded',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
};
|
||||
|
||||
export const isInListOperator: OperatorOption = {
|
||||
|
@ -68,7 +68,7 @@ export const isInListOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is_in_list',
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: 'included',
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
};
|
||||
|
||||
export const isNotInListOperator: OperatorOption = {
|
||||
|
@ -77,7 +77,7 @@ export const isNotInListOperator: OperatorOption = {
|
|||
}),
|
||||
value: 'is_not_in_list',
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: 'excluded',
|
||||
operator: OperatorEnum.EXCLUDED,
|
||||
};
|
||||
|
||||
export const EXCEPTION_OPERATORS: OperatorOption[] = [
|
|
@ -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,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const LOADING = i18n.translate('xpack.securitySolution.autocomplete.loadingDescription', {
|
||||
defaultMessage: 'Loading...',
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
|
||||
import { OperatorEnum, OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
|
||||
export interface GetGenericComboBoxPropsReturn {
|
||||
comboOptions: EuiComboBoxOptionOption[];
|
||||
labels: string[];
|
||||
selectedComboOptions: EuiComboBoxOptionOption[];
|
||||
}
|
||||
|
||||
export interface OperatorOption {
|
||||
message: string;
|
||||
value: string;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum;
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
import {
|
||||
QuerySuggestion,
|
||||
QuerySuggestionTypes,
|
||||
} from '../../../../../../../../src/plugins/data/public';
|
||||
import { SuggestionItem } from '../suggestion_item';
|
||||
|
||||
const suggestion: QuerySuggestion = {
|
||||
description: 'Description...',
|
||||
end: 3,
|
||||
start: 1,
|
||||
text: 'Text...',
|
||||
type: QuerySuggestionTypes.Value,
|
||||
};
|
||||
|
||||
storiesOf('components/SuggestionItem', module).add('example', () => (
|
||||
<ThemeProvider
|
||||
theme={() => ({
|
||||
eui: euiLightVars,
|
||||
darkMode: false,
|
||||
})}
|
||||
>
|
||||
<SuggestionItem suggestion={suggestion} />
|
||||
</ThemeProvider>
|
||||
));
|
|
@ -1,26 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Autocomplete rendering it renders against snapshot 1`] = `
|
||||
<EuiOutsideClickDetector
|
||||
onOutsideClick={[Function]}
|
||||
>
|
||||
<AutocompleteContainer>
|
||||
<EuiFieldSearch
|
||||
compressed={false}
|
||||
fullWidth={true}
|
||||
incremental={false}
|
||||
inputRef={[Function]}
|
||||
isClearable={true}
|
||||
isInvalid={true}
|
||||
isLoading={false}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
onSearch={[Function]}
|
||||
placeholder="myPlaceholder"
|
||||
value=""
|
||||
/>
|
||||
</AutocompleteContainer>
|
||||
</EuiOutsideClickDetector>
|
||||
`;
|
|
@ -1,388 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiFieldSearch } from '@elastic/eui';
|
||||
import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import { noop } from 'lodash/fp';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import {
|
||||
QuerySuggestion,
|
||||
QuerySuggestionTypes,
|
||||
} from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import { TestProviders } from '../../mock';
|
||||
|
||||
import { AutocompleteField } from '.';
|
||||
|
||||
const mockAutoCompleteData: QuerySuggestion[] = [
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.ephemeral_id ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.ephemeral_id</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.hostname ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.hostname</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.id ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.id</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.name ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.name</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.type ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.type</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.version ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.version</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.test1 ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test1</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.test2 ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test2</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.test3 ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test3</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
{
|
||||
type: QuerySuggestionTypes.Field,
|
||||
text: 'agent.test4 ',
|
||||
description:
|
||||
'<p>Filter results that contain <span class="suggestionItem__callout">agent.test4</span></p>',
|
||||
start: 0,
|
||||
end: 1,
|
||||
},
|
||||
];
|
||||
|
||||
describe('Autocomplete', () => {
|
||||
describe('rendering', () => {
|
||||
test('it renders against snapshot', () => {
|
||||
const placeholder = 'myPlaceholder';
|
||||
|
||||
const wrapper = shallow(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
placeholder={placeholder}
|
||||
suggestions={[]}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it is rendering with placeholder', () => {
|
||||
const placeholder = 'myPlaceholder';
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
placeholder={placeholder}
|
||||
suggestions={[]}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
const input = wrapper.find('input[type="search"]');
|
||||
expect(input.find('[placeholder]').props().placeholder).toEqual(placeholder);
|
||||
});
|
||||
|
||||
test('Rendering suggested items', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={''}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(10);
|
||||
});
|
||||
|
||||
test('Should Not render suggested items if loading new suggestions', () => {
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiDarkVars, darkMode: true })}>
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={true}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={''}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.euiPanel div[data-test-subj="suggestion-item"]').length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('events', () => {
|
||||
test('OnChange should have been called', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={[]}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('change', { target: { value: 'test' } });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('OnSubmit should have been called by keying enter on the search input', () => {
|
||||
const onSubmit = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={true}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={onSubmit}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={'filter: query'}
|
||||
/>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ selectedIndex: null });
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop });
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnSubmit should have been called by onSearch event on the input', () => {
|
||||
const onSubmit = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={true}
|
||||
loadSuggestions={noop}
|
||||
onChange={noop}
|
||||
onSubmit={onSubmit}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={'filter: query'}
|
||||
/>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ selectedIndex: null });
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find(EuiFieldSearch);
|
||||
// TODO: FixedEuiFieldSearch fails to import
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapperFixedEuiFieldSearch as any).props().onSearch();
|
||||
expect(onSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnChange should have been called if keying enter on a suggested item selected', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ selectedIndex: 1 });
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Enter', preventDefault: noop });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnChange should be called if tab is pressed when a suggested item is selected', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ selectedIndex: 1 });
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnChange should NOT be called if tab is pressed when more than one item is suggested, and no selection has been made', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={mockAutoCompleteData}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnChange should be called if tab is pressed when only one item is suggested, even though that item is NOT selected', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
const onlyOneSuggestion = [mockAutoCompleteData[0]];
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={onlyOneSuggestion}
|
||||
value={''}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const wrapperAutocompleteField = wrapper.find(AutocompleteField);
|
||||
wrapperAutocompleteField.setState({ areSuggestionsVisible: true });
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('OnChange should NOT be called if tab is pressed when 0 items are suggested', () => {
|
||||
const onChange = jest.fn((value: string) => value);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={noop}
|
||||
onChange={onChange}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={[]}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'Tab', preventDefault: noop });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Load more suggestions when arrowdown on the search bar', () => {
|
||||
const loadSuggestions = jest.fn(noop);
|
||||
|
||||
const wrapper = mount(
|
||||
<AutocompleteField
|
||||
isLoadingSuggestions={false}
|
||||
isValid={false}
|
||||
loadSuggestions={loadSuggestions}
|
||||
onChange={noop}
|
||||
onSubmit={noop}
|
||||
placeholder=""
|
||||
suggestions={[]}
|
||||
value={''}
|
||||
/>
|
||||
);
|
||||
const wrapperFixedEuiFieldSearch = wrapper.find('input');
|
||||
wrapperFixedEuiFieldSearch.simulate('keydown', { key: 'ArrowDown', preventDefault: noop });
|
||||
expect(loadSuggestions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -1,335 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFieldSearch,
|
||||
EuiFieldSearchProps,
|
||||
EuiOutsideClickDetector,
|
||||
EuiPanel,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { QuerySuggestion } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import euiStyled from '../../../../../../legacy/common/eui_styled_components';
|
||||
|
||||
import { SuggestionItem } from './suggestion_item';
|
||||
|
||||
interface AutocompleteFieldProps {
|
||||
'data-test-subj'?: string;
|
||||
isLoadingSuggestions: boolean;
|
||||
isValid: boolean;
|
||||
loadSuggestions: (value: string, cursorPosition: number, maxCount?: number) => void;
|
||||
onSubmit?: (value: string) => void;
|
||||
onChange?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
suggestions: QuerySuggestion[];
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AutocompleteFieldState {
|
||||
areSuggestionsVisible: boolean;
|
||||
isFocused: boolean;
|
||||
selectedIndex: number | null;
|
||||
}
|
||||
|
||||
export class AutocompleteField extends React.PureComponent<
|
||||
AutocompleteFieldProps,
|
||||
AutocompleteFieldState
|
||||
> {
|
||||
public readonly state: AutocompleteFieldState = {
|
||||
areSuggestionsVisible: false,
|
||||
isFocused: false,
|
||||
selectedIndex: null,
|
||||
};
|
||||
|
||||
private inputElement: HTMLInputElement | null = null;
|
||||
|
||||
public render() {
|
||||
const {
|
||||
'data-test-subj': dataTestSubj,
|
||||
suggestions,
|
||||
isLoadingSuggestions,
|
||||
isValid,
|
||||
placeholder,
|
||||
value,
|
||||
} = this.props;
|
||||
const { areSuggestionsVisible, selectedIndex } = this.state;
|
||||
return (
|
||||
<EuiOutsideClickDetector onOutsideClick={this.handleBlur}>
|
||||
<AutocompleteContainer>
|
||||
<FixedEuiFieldSearch
|
||||
data-test-subj={dataTestSubj}
|
||||
fullWidth
|
||||
inputRef={this.handleChangeInputRef}
|
||||
isLoading={isLoadingSuggestions}
|
||||
isInvalid={!isValid}
|
||||
onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
onSearch={this.submit}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
{areSuggestionsVisible && !isLoadingSuggestions && suggestions.length > 0 ? (
|
||||
<SuggestionsPanel>
|
||||
{suggestions.map((suggestion, suggestionIndex) => (
|
||||
<SuggestionItem
|
||||
key={suggestion.text}
|
||||
suggestion={suggestion}
|
||||
isSelected={suggestionIndex === selectedIndex}
|
||||
onMouseEnter={this.selectSuggestionAt(suggestionIndex)}
|
||||
onClick={this.applySuggestionAt(suggestionIndex)}
|
||||
/>
|
||||
))}
|
||||
</SuggestionsPanel>
|
||||
) : null}
|
||||
</AutocompleteContainer>
|
||||
</EuiOutsideClickDetector>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: AutocompleteFieldProps, prevState: AutocompleteFieldState) {
|
||||
const hasNewValue = prevProps.value !== this.props.value;
|
||||
const hasNewSuggestions = prevProps.suggestions !== this.props.suggestions;
|
||||
|
||||
if (hasNewValue) {
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
if (hasNewSuggestions && this.state.isFocused) {
|
||||
this.showSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
private handleChangeInputRef = (element: HTMLInputElement | null) => {
|
||||
this.inputElement = element;
|
||||
};
|
||||
|
||||
private handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.changeValue(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
private handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const { suggestions } = this.props;
|
||||
switch (evt.key) {
|
||||
case 'ArrowUp':
|
||||
evt.preventDefault();
|
||||
if (suggestions.length > 0) {
|
||||
this.setState(
|
||||
composeStateUpdaters(withSuggestionsVisible, withPreviousSuggestionSelected)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
evt.preventDefault();
|
||||
if (suggestions.length > 0) {
|
||||
this.setState(composeStateUpdaters(withSuggestionsVisible, withNextSuggestionSelected));
|
||||
} else {
|
||||
this.updateSuggestions();
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
evt.preventDefault();
|
||||
if (this.state.selectedIndex !== null) {
|
||||
this.applySelectedSuggestion();
|
||||
} else {
|
||||
this.submit();
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
evt.preventDefault();
|
||||
if (this.state.areSuggestionsVisible && this.props.suggestions.length === 1) {
|
||||
this.applySuggestionAt(0)();
|
||||
} else if (this.state.selectedIndex !== null) {
|
||||
this.applySelectedSuggestion();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
this.setState(withSuggestionsHidden);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private handleKeyUp = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
switch (evt.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowRight':
|
||||
case 'Home':
|
||||
case 'End':
|
||||
this.updateSuggestions();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFocus = () => {
|
||||
this.setState(composeStateUpdaters(withSuggestionsVisible, withFocused));
|
||||
};
|
||||
|
||||
private handleBlur = () => {
|
||||
this.setState(composeStateUpdaters(withSuggestionsHidden, withUnfocused));
|
||||
};
|
||||
|
||||
private selectSuggestionAt = (index: number) => () => {
|
||||
this.setState(withSuggestionAtIndexSelected(index));
|
||||
};
|
||||
|
||||
private applySelectedSuggestion = () => {
|
||||
if (this.state.selectedIndex !== null) {
|
||||
this.applySuggestionAt(this.state.selectedIndex)();
|
||||
}
|
||||
};
|
||||
|
||||
private applySuggestionAt = (index: number) => () => {
|
||||
const { value, suggestions } = this.props;
|
||||
const selectedSuggestion = suggestions[index];
|
||||
|
||||
if (!selectedSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue =
|
||||
value.substr(0, selectedSuggestion.start) +
|
||||
selectedSuggestion.text +
|
||||
value.substr(selectedSuggestion.end);
|
||||
|
||||
this.setState(withSuggestionsHidden);
|
||||
this.changeValue(newValue);
|
||||
this.focusInputElement();
|
||||
};
|
||||
|
||||
private changeValue = (value: string) => {
|
||||
const { onChange } = this.props;
|
||||
|
||||
if (onChange) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
private focusInputElement = () => {
|
||||
if (this.inputElement) {
|
||||
this.inputElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
private showSuggestions = () => {
|
||||
this.setState(withSuggestionsVisible);
|
||||
};
|
||||
|
||||
private submit = () => {
|
||||
const { isValid, onSubmit, value } = this.props;
|
||||
|
||||
if (isValid && onSubmit) {
|
||||
onSubmit(value);
|
||||
}
|
||||
|
||||
this.setState(withSuggestionsHidden);
|
||||
};
|
||||
|
||||
private updateSuggestions = () => {
|
||||
const inputCursorPosition = this.inputElement ? this.inputElement.selectionStart || 0 : 0;
|
||||
this.props.loadSuggestions(this.props.value, inputCursorPosition, 10);
|
||||
};
|
||||
}
|
||||
|
||||
type StateUpdater<State, Props = {}> = (
|
||||
prevState: Readonly<State>,
|
||||
prevProps: Readonly<Props>
|
||||
) => State | null;
|
||||
|
||||
function composeStateUpdaters<State, Props>(...updaters: Array<StateUpdater<State, Props>>) {
|
||||
return (state: State, props: Props) =>
|
||||
updaters.reduce((currentState, updater) => updater(currentState, props) || currentState, state);
|
||||
}
|
||||
|
||||
const withPreviousSuggestionSelected = (
|
||||
state: AutocompleteFieldState,
|
||||
props: AutocompleteFieldProps
|
||||
): AutocompleteFieldState => ({
|
||||
...state,
|
||||
selectedIndex:
|
||||
props.suggestions.length === 0
|
||||
? null
|
||||
: state.selectedIndex !== null
|
||||
? (state.selectedIndex + props.suggestions.length - 1) % props.suggestions.length
|
||||
: Math.max(props.suggestions.length - 1, 0),
|
||||
});
|
||||
|
||||
const withNextSuggestionSelected = (
|
||||
state: AutocompleteFieldState,
|
||||
props: AutocompleteFieldProps
|
||||
): AutocompleteFieldState => ({
|
||||
...state,
|
||||
selectedIndex:
|
||||
props.suggestions.length === 0
|
||||
? null
|
||||
: state.selectedIndex !== null
|
||||
? (state.selectedIndex + 1) % props.suggestions.length
|
||||
: 0,
|
||||
});
|
||||
|
||||
const withSuggestionAtIndexSelected = (suggestionIndex: number) => (
|
||||
state: AutocompleteFieldState,
|
||||
props: AutocompleteFieldProps
|
||||
): AutocompleteFieldState => ({
|
||||
...state,
|
||||
selectedIndex:
|
||||
props.suggestions.length === 0
|
||||
? null
|
||||
: suggestionIndex >= 0 && suggestionIndex < props.suggestions.length
|
||||
? suggestionIndex
|
||||
: 0,
|
||||
});
|
||||
|
||||
const withSuggestionsVisible = (state: AutocompleteFieldState) => ({
|
||||
...state,
|
||||
areSuggestionsVisible: true,
|
||||
});
|
||||
|
||||
const withSuggestionsHidden = (state: AutocompleteFieldState) => ({
|
||||
...state,
|
||||
areSuggestionsVisible: false,
|
||||
selectedIndex: null,
|
||||
});
|
||||
|
||||
const withFocused = (state: AutocompleteFieldState) => ({
|
||||
...state,
|
||||
isFocused: true,
|
||||
});
|
||||
|
||||
const withUnfocused = (state: AutocompleteFieldState) => ({
|
||||
...state,
|
||||
isFocused: false,
|
||||
});
|
||||
|
||||
export const FixedEuiFieldSearch: React.FC<
|
||||
React.InputHTMLAttributes<HTMLInputElement> &
|
||||
EuiFieldSearchProps & {
|
||||
inputRef?: (element: HTMLInputElement | null) => void;
|
||||
onSearch: (value: string) => void;
|
||||
}
|
||||
> = EuiFieldSearch as any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const AutocompleteContainer = euiStyled.div`
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
AutocompleteContainer.displayName = 'AutocompleteContainer';
|
||||
|
||||
const SuggestionsPanel = euiStyled(EuiPanel).attrs(() => ({
|
||||
paddingSize: 'none',
|
||||
hasShadow: true,
|
||||
}))`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
z-index: ${(props) => props.theme.eui.euiZLevel1};
|
||||
`;
|
||||
|
||||
SuggestionsPanel.displayName = 'SuggestionsPanel';
|
|
@ -1,131 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import { transparentize } from 'polished';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import euiStyled from '../../../../../../legacy/common/eui_styled_components';
|
||||
import { QuerySuggestion } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
interface SuggestionItemProps {
|
||||
isSelected?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
|
||||
suggestion: QuerySuggestion;
|
||||
}
|
||||
|
||||
export const SuggestionItem = React.memo<SuggestionItemProps>(
|
||||
({ isSelected = false, onClick, onMouseEnter, suggestion }) => {
|
||||
return (
|
||||
<SuggestionItemContainer
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
data-test-subj="suggestion-item"
|
||||
>
|
||||
<SuggestionItemIconField suggestionType={suggestion.type}>
|
||||
<EuiIcon type={getEuiIconType(suggestion.type)} />
|
||||
</SuggestionItemIconField>
|
||||
<SuggestionItemTextField>{suggestion.text}</SuggestionItemTextField>
|
||||
<SuggestionItemDescriptionField>{suggestion.description}</SuggestionItemDescriptionField>
|
||||
</SuggestionItemContainer>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SuggestionItem.displayName = 'SuggestionItem';
|
||||
|
||||
const SuggestionItemContainer = euiStyled.div<{
|
||||
isSelected?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: ${(props) => props.theme.eui.euiFontSizeS};
|
||||
height: ${(props) => props.theme.eui.euiSizeXL};
|
||||
white-space: nowrap;
|
||||
background-color: ${(props) =>
|
||||
props.isSelected ? props.theme.eui.euiColorLightestShade : 'transparent'};
|
||||
`;
|
||||
|
||||
SuggestionItemContainer.displayName = 'SuggestionItemContainer';
|
||||
|
||||
const SuggestionItemField = euiStyled.div`
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: ${(props) => props.theme.eui.euiSizeXL};
|
||||
padding: ${(props) => props.theme.eui.euiSizeXS};
|
||||
`;
|
||||
|
||||
SuggestionItemField.displayName = 'SuggestionItemField';
|
||||
|
||||
const SuggestionItemIconField = styled(SuggestionItemField)<{ suggestionType: string }>`
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.9, getEuiIconColor(props.theme, props.suggestionType))};
|
||||
color: ${(props) => getEuiIconColor(props.theme, props.suggestionType)};
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
width: ${(props) => props.theme.eui.euiSizeXL};
|
||||
`;
|
||||
|
||||
SuggestionItemIconField.displayName = 'SuggestionItemIconField';
|
||||
|
||||
const SuggestionItemTextField = styled(SuggestionItemField)`
|
||||
flex: 2 0 0;
|
||||
font-family: ${(props) => props.theme.eui.euiCodeFontFamily};
|
||||
`;
|
||||
|
||||
SuggestionItemTextField.displayName = 'SuggestionItemTextField';
|
||||
|
||||
const SuggestionItemDescriptionField = styled(SuggestionItemField)`
|
||||
flex: 3 0 0;
|
||||
|
||||
p {
|
||||
display: inline;
|
||||
|
||||
span {
|
||||
font-family: ${(props) => props.theme.eui.euiCodeFontFamily};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
SuggestionItemDescriptionField.displayName = 'SuggestionItemDescriptionField';
|
||||
|
||||
const getEuiIconType = (suggestionType: string) => {
|
||||
switch (suggestionType) {
|
||||
case 'field':
|
||||
return 'kqlField';
|
||||
case 'value':
|
||||
return 'kqlValue';
|
||||
case 'recentSearch':
|
||||
return 'search';
|
||||
case 'conjunction':
|
||||
return 'kqlSelector';
|
||||
case 'operator':
|
||||
return 'kqlOperand';
|
||||
default:
|
||||
return 'empty';
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEuiIconColor = (theme: any, suggestionType: string): string => {
|
||||
switch (suggestionType) {
|
||||
case 'field':
|
||||
return theme.eui.euiColorVis7;
|
||||
case 'value':
|
||||
return theme.eui.euiColorVis0;
|
||||
case 'operator':
|
||||
return theme.eui.euiColorVis1;
|
||||
case 'conjunction':
|
||||
return theme.eui.euiColorVis2;
|
||||
case 'recentSearch':
|
||||
default:
|
||||
return theme.eui.euiColorMediumShade;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { storiesOf, addDecorator } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import React from 'react';
|
||||
import { ThemeProvider } from 'styled-components';
|
||||
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
|
||||
|
||||
import { BuilderButtonOptions } from './builder_button_options';
|
||||
|
||||
addDecorator((storyFn) => (
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
|
||||
));
|
||||
|
||||
storiesOf('Components|Exceptions|BuilderButtonOptions', module)
|
||||
.add('init button', () => {
|
||||
return (
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton
|
||||
onOrClicked={action('onClick')}
|
||||
onAndClicked={action('onClick')}
|
||||
onNestedClicked={action('onClick')}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('and/or buttons', () => {
|
||||
return (
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={action('onClick')}
|
||||
onAndClicked={action('onClick')}
|
||||
onNestedClicked={action('onClick')}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('nested button', () => {
|
||||
return (
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton
|
||||
displayInitButton={false}
|
||||
onOrClicked={action('onClick')}
|
||||
onAndClicked={action('onClick')}
|
||||
onNestedClicked={action('onClick')}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('and disabled', () => {
|
||||
return (
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={action('onClick')}
|
||||
onAndClicked={action('onClick')}
|
||||
onNestedClicked={action('onClick')}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('or disabled', () => {
|
||||
return (
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={action('onClick')}
|
||||
onAndClicked={action('onClick')}
|
||||
onNestedClicked={action('onClick')}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -0,0 +1,167 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import React from 'react';
|
||||
|
||||
import { BuilderButtonOptions } from './builder_button_options';
|
||||
|
||||
describe('BuilderButtonOptions', () => {
|
||||
test('it renders "and" and "or" buttons', () => {
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="exceptionsAndButton"] button')).toHaveLength(1);
|
||||
expect(wrapper.find('[data-test-subj="exceptionsOrButton"] button')).toHaveLength(1);
|
||||
expect(wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button')).toHaveLength(
|
||||
0
|
||||
);
|
||||
expect(wrapper.find('[data-test-subj="exceptionsNestedButton"] button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('it renders "add exception" button if "displayInitButton" is true', () => {
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button')).toHaveLength(
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test('it invokes "onAddExceptionClicked" when "add exception" button is clicked', () => {
|
||||
const onOrClicked = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton
|
||||
onOrClicked={onOrClicked}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="exceptionsAddNewExceptionButton"] button').simulate('click');
|
||||
|
||||
expect(onOrClicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('it invokes "onOrClicked" when "or" button is clicked', () => {
|
||||
const onOrClicked = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={onOrClicked}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="exceptionsOrButton"] button').simulate('click');
|
||||
|
||||
expect(onOrClicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('it invokes "onAndClicked" when "and" button is clicked', () => {
|
||||
const onAndClicked = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={onAndClicked}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="exceptionsAndButton"] button').simulate('click');
|
||||
|
||||
expect(onAndClicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('it disables "and" button if "isAndDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
isOrDisabled={false}
|
||||
isAndDisabled
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const andButton = wrapper.find('[data-test-subj="exceptionsAndButton"] button').at(0);
|
||||
|
||||
expect(andButton.prop('disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it disables "or" button if "isOrDisabled" is true', () => {
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
showNestedButton={false}
|
||||
displayInitButton={false}
|
||||
isOrDisabled
|
||||
isAndDisabled={false}
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={jest.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const orButton = wrapper.find('[data-test-subj="exceptionsOrButton"] button').at(0);
|
||||
|
||||
expect(orButton.prop('disabled')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('it invokes "onNestedClicked" when "and" button is clicked', () => {
|
||||
const onNestedClicked = jest.fn();
|
||||
|
||||
const wrapper = mount(
|
||||
<BuilderButtonOptions
|
||||
isAndDisabled={false}
|
||||
isOrDisabled={false}
|
||||
showNestedButton
|
||||
displayInitButton={false}
|
||||
onOrClicked={jest.fn()}
|
||||
onAndClicked={jest.fn()}
|
||||
onNestedClicked={onNestedClicked}
|
||||
/>
|
||||
);
|
||||
|
||||
wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click');
|
||||
|
||||
expect(onNestedClicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import * as i18n from '../translations';
|
||||
|
||||
const MyEuiButton = styled(EuiButton)`
|
||||
min-width: 95px;
|
||||
`;
|
||||
|
||||
interface BuilderButtonOptionsProps {
|
||||
isOrDisabled: boolean;
|
||||
isAndDisabled: boolean;
|
||||
displayInitButton: boolean;
|
||||
showNestedButton: boolean;
|
||||
onAndClicked: () => void;
|
||||
onOrClicked: () => void;
|
||||
onNestedClicked: () => void;
|
||||
}
|
||||
|
||||
export const BuilderButtonOptions: React.FC<BuilderButtonOptionsProps> = ({
|
||||
isOrDisabled = false,
|
||||
isAndDisabled = false,
|
||||
displayInitButton,
|
||||
showNestedButton = false,
|
||||
onAndClicked,
|
||||
onOrClicked,
|
||||
onNestedClicked,
|
||||
}) => (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
{displayInitButton ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
fill
|
||||
size="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={onOrClicked}
|
||||
data-test-subj="exceptionsAddNewExceptionButton"
|
||||
>
|
||||
{i18n.ADD_EXCEPTION_TITLE}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<>
|
||||
<EuiFlexItem grow={false}>
|
||||
<MyEuiButton
|
||||
fill
|
||||
size="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={onAndClicked}
|
||||
data-test-subj="exceptionsAndButton"
|
||||
isDisabled={isAndDisabled}
|
||||
>
|
||||
{i18n.AND}
|
||||
</MyEuiButton>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<MyEuiButton
|
||||
fill
|
||||
size="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={onOrClicked}
|
||||
isDisabled={isOrDisabled}
|
||||
data-test-subj="exceptionsOrButton"
|
||||
>
|
||||
{i18n.OR}
|
||||
</MyEuiButton>
|
||||
</EuiFlexItem>
|
||||
{showNestedButton && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
size="s"
|
||||
iconType="nested"
|
||||
onClick={onNestedClicked}
|
||||
data-test-subj="exceptionsNestedButton"
|
||||
>
|
||||
{i18n.ADD_NESTED_DESCRIPTION}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
|
@ -0,0 +1,243 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { FieldComponent } from '../../autocomplete/field';
|
||||
import { OperatorComponent } from '../../autocomplete/operator';
|
||||
import { isOperator } from '../../autocomplete/operators';
|
||||
import { OperatorOption } from '../../autocomplete/types';
|
||||
import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match';
|
||||
import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any';
|
||||
import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists';
|
||||
import { FormattedBuilderEntry, BuilderEntry } from '../types';
|
||||
import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists';
|
||||
import { ListSchema, OperatorTypeEnum } from '../../../../lists_plugin_deps';
|
||||
import { getValueFromOperator } from '../helpers';
|
||||
import { getEmptyValue } from '../../empty_value';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface EntryItemProps {
|
||||
entry: FormattedBuilderEntry;
|
||||
entryIndex: number;
|
||||
indexPattern: IIndexPattern;
|
||||
isLoading: boolean;
|
||||
showLabel: boolean;
|
||||
onChange: (arg: BuilderEntry, i: number) => void;
|
||||
}
|
||||
|
||||
export const EntryItemComponent: React.FC<EntryItemProps> = ({
|
||||
entry,
|
||||
entryIndex,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
showLabel,
|
||||
onChange,
|
||||
}): JSX.Element => {
|
||||
const handleFieldChange = useCallback(
|
||||
([newField]: IFieldType[]): void => {
|
||||
onChange(
|
||||
{
|
||||
field: newField.name,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: isOperator.operator,
|
||||
value: undefined,
|
||||
},
|
||||
entryIndex
|
||||
);
|
||||
},
|
||||
[onChange, entryIndex]
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
([newOperator]: OperatorOption[]): void => {
|
||||
const newEntry = getValueFromOperator(entry.field, newOperator);
|
||||
onChange(newEntry, entryIndex);
|
||||
},
|
||||
[onChange, entryIndex, entry.field]
|
||||
);
|
||||
|
||||
const handleFieldMatchValueChange = useCallback(
|
||||
(newField: string): void => {
|
||||
onChange(
|
||||
{
|
||||
field: entry.field != null ? entry.field.name : undefined,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: isOperator.operator,
|
||||
value: newField,
|
||||
},
|
||||
entryIndex
|
||||
);
|
||||
},
|
||||
[onChange, entryIndex, entry.field]
|
||||
);
|
||||
|
||||
const handleFieldMatchAnyValueChange = useCallback(
|
||||
(newField: string[]): void => {
|
||||
onChange(
|
||||
{
|
||||
field: entry.field != null ? entry.field.name : undefined,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: isOperator.operator,
|
||||
value: newField,
|
||||
},
|
||||
entryIndex
|
||||
);
|
||||
},
|
||||
[onChange, entryIndex, entry.field]
|
||||
);
|
||||
|
||||
const handleFieldListValueChange = useCallback(
|
||||
(newField: ListSchema): void => {
|
||||
onChange(
|
||||
{
|
||||
field: entry.field != null ? entry.field.name : undefined,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: isOperator.operator,
|
||||
list: { id: newField.id, type: newField.type },
|
||||
},
|
||||
entryIndex
|
||||
);
|
||||
},
|
||||
[onChange, entryIndex, entry.field]
|
||||
);
|
||||
|
||||
const renderFieldInput = (isFirst: boolean): JSX.Element => {
|
||||
const comboBox = (
|
||||
<FieldComponent
|
||||
placeholder={i18n.EXCEPTION_FIELD_PLACEHOLDER}
|
||||
indexPattern={indexPattern}
|
||||
selectedField={entry.field}
|
||||
isLoading={isLoading}
|
||||
isClearable={false}
|
||||
isDisabled={indexPattern == null}
|
||||
onChange={handleFieldChange}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFirst) {
|
||||
return (
|
||||
<EuiFormRow label={i18n.FIELD} data-test-subj="exceptionBuilderEntryFieldFormRow">
|
||||
{comboBox}
|
||||
</EuiFormRow>
|
||||
);
|
||||
} else {
|
||||
return comboBox;
|
||||
}
|
||||
};
|
||||
|
||||
const renderOperatorInput = (isFirst: boolean): JSX.Element => {
|
||||
const comboBox = (
|
||||
<OperatorComponent
|
||||
placeholder={i18n.EXCEPTION_OPERATOR_PLACEHOLDER}
|
||||
selectedField={entry.field}
|
||||
operator={entry.operator}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
isClearable={false}
|
||||
onChange={handleOperatorChange}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFirst) {
|
||||
return (
|
||||
<EuiFormRow label={i18n.OPERATOR} data-test-subj="exceptionBuilderEntryFieldFormRow">
|
||||
{comboBox}
|
||||
</EuiFormRow>
|
||||
);
|
||||
} else {
|
||||
return comboBox;
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldValueComboBox = (type: OperatorTypeEnum): JSX.Element => {
|
||||
switch (type) {
|
||||
case OperatorTypeEnum.MATCH:
|
||||
const value = typeof entry.value === 'string' ? entry.value : undefined;
|
||||
return (
|
||||
<AutocompleteFieldMatchComponent
|
||||
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={entry.field}
|
||||
selectedValue={value}
|
||||
isDisabled={false}
|
||||
isLoading={isLoading}
|
||||
isClearable={false}
|
||||
indexPattern={indexPattern}
|
||||
onChange={handleFieldMatchValueChange}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
);
|
||||
case OperatorTypeEnum.MATCH_ANY:
|
||||
const values: string[] = Array.isArray(entry.value) ? entry.value : [];
|
||||
return (
|
||||
<AutocompleteFieldMatchAnyComponent
|
||||
placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER}
|
||||
selectedField={entry.field}
|
||||
selectedValue={values}
|
||||
isDisabled={false}
|
||||
isLoading={isLoading}
|
||||
isClearable={false}
|
||||
indexPattern={indexPattern}
|
||||
onChange={handleFieldMatchAnyValueChange}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
);
|
||||
case OperatorTypeEnum.LIST:
|
||||
const id = typeof entry.value === 'string' ? entry.value : undefined;
|
||||
return (
|
||||
<AutocompleteFieldListsComponent
|
||||
selectedField={entry.field}
|
||||
placeholder={i18n.EXCEPTION_FIELD_LISTS_PLACEHOLDER}
|
||||
selectedValue={id}
|
||||
isLoading={false}
|
||||
isDisabled={false}
|
||||
isClearable={false}
|
||||
onChange={handleFieldListValueChange}
|
||||
/>
|
||||
);
|
||||
case OperatorTypeEnum.EXISTS:
|
||||
return (
|
||||
<AutocompleteFieldExistsComponent
|
||||
placeholder={getEmptyValue()}
|
||||
data-test-subj="filterFieldSuggestionList"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderFieldValueInput = (isFirst: boolean, entryType: OperatorTypeEnum): JSX.Element => {
|
||||
if (isFirst) {
|
||||
return (
|
||||
<EuiFormRow label={i18n.VALUE} fullWidth data-test-subj="exceptionBuilderEntryFieldFormRow">
|
||||
{getFieldValueComboBox(entryType)}
|
||||
</EuiFormRow>
|
||||
);
|
||||
} else {
|
||||
return getFieldValueComboBox(entryType);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
direction="row"
|
||||
gutterSize="s"
|
||||
alignItems="center"
|
||||
className="exceptionItemEntryContainer"
|
||||
data-test-subj="exceptionItemEntryContainer"
|
||||
>
|
||||
<EuiFlexItem grow={false}>{renderFieldInput(showLabel)}</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{renderOperatorInput(showLabel)}</EuiFlexItem>
|
||||
<EuiFlexItem grow={6}>{renderFieldValueInput(showLabel, entry.operator.type)}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
EntryItemComponent.displayName = 'EntryItem';
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import { EntryItemComponent } from './entry_item';
|
||||
import { getFormattedBuilderEntries } from '../helpers';
|
||||
import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types';
|
||||
|
||||
const MyInvisibleAndBadge = styled(EuiFlexItem)`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
const MyFirstRowContainer = styled(EuiFlexItem)`
|
||||
padding-top: 20px;
|
||||
`;
|
||||
|
||||
interface ExceptionListItemProps {
|
||||
exceptionItem: ExceptionsBuilderExceptionItem;
|
||||
exceptionId: string;
|
||||
exceptionItemIndex: number;
|
||||
isLoading: boolean;
|
||||
indexPattern: IIndexPattern;
|
||||
andLogicIncluded: boolean;
|
||||
onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void;
|
||||
onExceptionItemChange: (item: ExceptionsBuilderExceptionItem, index: number) => void;
|
||||
}
|
||||
|
||||
export const ExceptionListItemComponent = React.memo<ExceptionListItemProps>(
|
||||
({
|
||||
exceptionItem,
|
||||
exceptionId,
|
||||
exceptionItemIndex,
|
||||
indexPattern,
|
||||
isLoading,
|
||||
andLogicIncluded,
|
||||
onDeleteExceptionItem,
|
||||
onExceptionItemChange,
|
||||
}) => {
|
||||
const handleEntryChange = (entry: BuilderEntry, entryIndex: number): void => {
|
||||
const updatedEntries: BuilderEntry[] = [
|
||||
...exceptionItem.entries.slice(0, entryIndex),
|
||||
{ ...entry },
|
||||
...exceptionItem.entries.slice(entryIndex + 1),
|
||||
];
|
||||
const updatedExceptionItem: ExceptionsBuilderExceptionItem = {
|
||||
...exceptionItem,
|
||||
entries: updatedEntries,
|
||||
};
|
||||
onExceptionItemChange(updatedExceptionItem, exceptionItemIndex);
|
||||
};
|
||||
|
||||
const handleDeleteEntry = (entryIndex: number): void => {
|
||||
const updatedEntries: BuilderEntry[] = [
|
||||
...exceptionItem.entries.slice(0, entryIndex),
|
||||
...exceptionItem.entries.slice(entryIndex + 1),
|
||||
];
|
||||
const updatedExceptionItem: ExceptionsBuilderExceptionItem = {
|
||||
...exceptionItem,
|
||||
entries: updatedEntries,
|
||||
};
|
||||
|
||||
onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex);
|
||||
};
|
||||
|
||||
const entries = useMemo(
|
||||
(): FormattedBuilderEntry[] =>
|
||||
indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [],
|
||||
[indexPattern, exceptionItem.entries]
|
||||
);
|
||||
|
||||
const andBadge = useMemo((): JSX.Element => {
|
||||
const badge = <AndOrBadge includeAntennas type="and" />;
|
||||
if (entries.length > 1 && exceptionItemIndex === 0) {
|
||||
return <MyFirstRowContainer grow={false}>{badge}</MyFirstRowContainer>;
|
||||
} else if (entries.length > 1) {
|
||||
return <EuiFlexItem grow={false}>{badge}</EuiFlexItem>;
|
||||
} else {
|
||||
return <MyInvisibleAndBadge grow={false}>{badge}</MyInvisibleAndBadge>;
|
||||
}
|
||||
}, [entries.length, exceptionItemIndex]);
|
||||
|
||||
const getDeleteButton = (index: number): JSX.Element => {
|
||||
const button = (
|
||||
<EuiButtonIcon
|
||||
color="danger"
|
||||
iconType="trash"
|
||||
onClick={() => handleDeleteEntry(index)}
|
||||
aria-label="entryDeleteButton"
|
||||
className="exceptionItemEntryDeleteButton"
|
||||
data-test-subj="exceptionItemEntryDeleteButton"
|
||||
/>
|
||||
);
|
||||
if (index === 0 && exceptionItemIndex === 0) {
|
||||
return <MyFirstRowContainer grow={false}>{button}</MyFirstRowContainer>;
|
||||
} else {
|
||||
return <EuiFlexItem grow={false}>{button}</EuiFlexItem>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" data-test-subj="exceptionEntriesContainer">
|
||||
{andLogicIncluded && andBadge}
|
||||
<EuiFlexItem grow={6}>
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
{entries.map((item, index) => (
|
||||
<EuiFlexItem key={`${exceptionId}-${index}`} grow={1}>
|
||||
<EuiFlexGroup gutterSize="xs" alignItems="center" direction="row">
|
||||
<EuiFlexItem grow={1}>
|
||||
<EntryItemComponent
|
||||
entry={item}
|
||||
entryIndex={index}
|
||||
indexPattern={indexPattern}
|
||||
showLabel={exceptionItemIndex === 0 && index === 0}
|
||||
isLoading={isLoading}
|
||||
onChange={handleEntryChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
{getDeleteButton(index)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ExceptionListItemComponent.displayName = 'ExceptionListItem';
|
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useMemo, useCallback, useEffect, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ExceptionListItemComponent } from './exception_item';
|
||||
import { useFetchIndexPatterns } from '../../../../alerts/containers/detection_engine/rules/fetch_index_patterns';
|
||||
import {
|
||||
ExceptionListItemSchema,
|
||||
NamespaceType,
|
||||
exceptionListItemSchema,
|
||||
OperatorTypeEnum,
|
||||
OperatorEnum,
|
||||
CreateExceptionListItemSchema,
|
||||
} from '../../../../../public/lists_plugin_deps';
|
||||
import { AndOrBadge } from '../../and_or_badge';
|
||||
import { BuilderButtonOptions } from './builder_button_options';
|
||||
import { getNewExceptionItem, filterExceptionItems } from '../helpers';
|
||||
import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types';
|
||||
import { Loader } from '../../loader';
|
||||
|
||||
const MyInvisibleAndBadge = styled(EuiFlexItem)`
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
const MyAndBadge = styled(AndOrBadge)`
|
||||
& > .euiFlexItem {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const MyButtonsContainer = styled(EuiFlexItem)`
|
||||
margin: 16px 0;
|
||||
`;
|
||||
|
||||
interface OnChangeProps {
|
||||
exceptionItems: Array<ExceptionListItemSchema | CreateExceptionListItemSchema>;
|
||||
exceptionsToDelete: ExceptionListItemSchema[];
|
||||
}
|
||||
|
||||
interface ExceptionBuilderProps {
|
||||
exceptionListItems: ExceptionListItemSchema[];
|
||||
listType: 'detection' | 'endpoint';
|
||||
listId: string;
|
||||
listNamespaceType: NamespaceType;
|
||||
ruleName: string;
|
||||
indexPatternConfig: string[];
|
||||
isLoading: boolean;
|
||||
isOrDisabled: boolean;
|
||||
isAndDisabled: boolean;
|
||||
onChange: (arg: OnChangeProps) => void;
|
||||
}
|
||||
|
||||
export const ExceptionBuilder = ({
|
||||
exceptionListItems,
|
||||
listType,
|
||||
listId,
|
||||
listNamespaceType,
|
||||
ruleName,
|
||||
indexPatternConfig,
|
||||
isLoading,
|
||||
isOrDisabled,
|
||||
isAndDisabled,
|
||||
onChange,
|
||||
}: ExceptionBuilderProps) => {
|
||||
const [andLogicIncluded, setAndLogicIncluded] = useState<boolean>(false);
|
||||
const [exceptions, setExceptions] = useState<ExceptionsBuilderExceptionItem[]>(
|
||||
exceptionListItems
|
||||
);
|
||||
const [exceptionsToDelete, setExceptionsToDelete] = useState<ExceptionListItemSchema[]>([]);
|
||||
const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns(
|
||||
indexPatternConfig ?? []
|
||||
);
|
||||
|
||||
// Bubble up changes to parent
|
||||
useEffect(() => {
|
||||
onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete });
|
||||
}, [onChange, exceptionsToDelete, exceptions]);
|
||||
|
||||
const checkAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => {
|
||||
setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0);
|
||||
};
|
||||
|
||||
const handleDeleteExceptionItem = (
|
||||
item: ExceptionsBuilderExceptionItem,
|
||||
itemIndex: number
|
||||
): void => {
|
||||
if (item.entries.length === 0) {
|
||||
if (exceptionListItemSchema.is(item)) {
|
||||
setExceptionsToDelete((items) => [...items, item]);
|
||||
}
|
||||
|
||||
setExceptions((existingExceptions) => {
|
||||
const updatedExceptions = [
|
||||
...existingExceptions.slice(0, itemIndex),
|
||||
...existingExceptions.slice(itemIndex + 1),
|
||||
];
|
||||
checkAndLogic(updatedExceptions);
|
||||
|
||||
return updatedExceptions;
|
||||
});
|
||||
} else {
|
||||
handleExceptionItemChange(item, itemIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExceptionItemChange = (item: ExceptionsBuilderExceptionItem, index: number): void => {
|
||||
const updatedExceptions = [
|
||||
...exceptions.slice(0, index),
|
||||
{
|
||||
...item,
|
||||
},
|
||||
...exceptions.slice(index + 1),
|
||||
];
|
||||
|
||||
checkAndLogic(updatedExceptions);
|
||||
setExceptions(updatedExceptions);
|
||||
};
|
||||
|
||||
const handleAddNewExceptionItemEntry = useCallback((): void => {
|
||||
setExceptions((existingExceptions): ExceptionsBuilderExceptionItem[] => {
|
||||
const lastException = existingExceptions[existingExceptions.length - 1];
|
||||
const { entries } = lastException;
|
||||
const updatedException: ExceptionsBuilderExceptionItem = {
|
||||
...lastException,
|
||||
entries: [
|
||||
...entries,
|
||||
{ field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '' },
|
||||
],
|
||||
};
|
||||
|
||||
setAndLogicIncluded(updatedException.entries.length > 1);
|
||||
|
||||
return [
|
||||
...existingExceptions.slice(0, existingExceptions.length - 1),
|
||||
{ ...updatedException },
|
||||
];
|
||||
});
|
||||
}, [setExceptions, setAndLogicIncluded]);
|
||||
|
||||
const handleAddNewExceptionItem = useCallback((): void => {
|
||||
// There is a case where there are numerous exception list items, all with
|
||||
// empty `entries` array. Thought about appending an entry item to one, but that
|
||||
// would then be arbitrary, decided to just create a new exception list item
|
||||
const newException = getNewExceptionItem({
|
||||
listType,
|
||||
listId,
|
||||
namespaceType: listNamespaceType,
|
||||
ruleName,
|
||||
});
|
||||
setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]);
|
||||
}, [setExceptions, listType, listId, listNamespaceType, ruleName]);
|
||||
|
||||
// An exception item can have an empty array for `entries`
|
||||
const displayInitialAddExceptionButton = useMemo((): boolean => {
|
||||
return (
|
||||
exceptions.length === 0 ||
|
||||
(exceptions.length === 1 &&
|
||||
exceptions[0].entries != null &&
|
||||
exceptions[0].entries.length === 0)
|
||||
);
|
||||
}, [exceptions]);
|
||||
|
||||
// The builder can have existing exception items, or new exception items that have yet
|
||||
// to be created (and thus lack an id), this was creating some React bugs with relying
|
||||
// on the index, as a result, created a temporary id when new exception items are first
|
||||
// instantiated that is stored in `meta` that gets stripped on it's way out
|
||||
const getExceptionListItemId = (item: ExceptionsBuilderExceptionItem, index: number): string => {
|
||||
if ((item as ExceptionListItemSchema).id != null) {
|
||||
return (item as ExceptionListItemSchema).id;
|
||||
} else if ((item as CreateExceptionListItemBuilderSchema).meta.temporaryUuid != null) {
|
||||
return (item as CreateExceptionListItemBuilderSchema).meta.temporaryUuid;
|
||||
} else {
|
||||
return `${index}`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
{(isLoading || indexPatternLoading) && (
|
||||
<Loader data-test-subj="loadingPanelAllRulesTable" overlay size="xl" />
|
||||
)}
|
||||
{exceptions.map((exceptionListItem, index) => (
|
||||
<EuiFlexItem grow={1} key={getExceptionListItemId(exceptionListItem, index)}>
|
||||
<EuiFlexGroup gutterSize="s" direction="column">
|
||||
{index !== 0 &&
|
||||
(andLogicIncluded ? (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup gutterSize="none" direction="row">
|
||||
<MyInvisibleAndBadge grow={false}>
|
||||
<MyAndBadge includeAntennas type="and" />
|
||||
</MyInvisibleAndBadge>
|
||||
<EuiFlexItem grow={false}>
|
||||
<MyAndBadge type="or" />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<EuiFlexItem grow={false}>
|
||||
<MyAndBadge type="or" />
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
<EuiFlexItem grow={false}>
|
||||
<ExceptionListItemComponent
|
||||
key={getExceptionListItemId(exceptionListItem, index)}
|
||||
exceptionItem={exceptionListItem}
|
||||
exceptionId={getExceptionListItemId(exceptionListItem, index)}
|
||||
indexPattern={indexPatterns}
|
||||
isLoading={indexPatternLoading}
|
||||
exceptionItemIndex={index}
|
||||
andLogicIncluded={andLogicIncluded}
|
||||
onDeleteExceptionItem={handleDeleteExceptionItem}
|
||||
onExceptionItemChange={handleExceptionItemChange}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
|
||||
<MyButtonsContainer data-test-subj={`andOrOperatorButtons`}>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
{andLogicIncluded && (
|
||||
<MyInvisibleAndBadge grow={false}>
|
||||
<AndOrBadge includeAntennas type="and" />
|
||||
</MyInvisibleAndBadge>
|
||||
)}
|
||||
<EuiFlexItem grow={1}>
|
||||
<BuilderButtonOptions
|
||||
isOrDisabled={isOrDisabled}
|
||||
isAndDisabled={isAndDisabled}
|
||||
displayInitButton={displayInitialAddExceptionButton}
|
||||
showNestedButton={false}
|
||||
onOrClicked={handleAddNewExceptionItem}
|
||||
onAndClicked={handleAddNewExceptionItemEntry}
|
||||
onNestedClicked={() => {}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</MyButtonsContainer>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
ExceptionBuilder.displayName = 'ExceptionBuilder';
|
|
@ -16,8 +16,10 @@ import {
|
|||
getTagsInclude,
|
||||
getDescriptionListContent,
|
||||
getFormattedComments,
|
||||
filterExceptionItems,
|
||||
getNewExceptionItem,
|
||||
} from './helpers';
|
||||
import { FormattedEntry, DescriptionListItem } from './types';
|
||||
import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types';
|
||||
import {
|
||||
isOperator,
|
||||
isNotOperator,
|
||||
|
@ -27,8 +29,8 @@ import {
|
|||
isNotInListOperator,
|
||||
existsOperator,
|
||||
doesNotExistOperator,
|
||||
} from './operators';
|
||||
import { OperatorTypeEnum } from '../../../lists_plugin_deps';
|
||||
} from '../autocomplete/operators';
|
||||
import { OperatorTypeEnum, OperatorEnum } from '../../../lists_plugin_deps';
|
||||
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
|
||||
import {
|
||||
getEntryExistsMock,
|
||||
|
@ -169,7 +171,7 @@ describe('Exception helpers', () => {
|
|||
fieldName: 'host.name',
|
||||
isNested: false,
|
||||
operator: 'exists',
|
||||
value: null,
|
||||
value: undefined,
|
||||
},
|
||||
];
|
||||
expect(result).toEqual(expected);
|
||||
|
@ -221,13 +223,13 @@ describe('Exception helpers', () => {
|
|||
fieldName: 'host.name',
|
||||
isNested: false,
|
||||
operator: 'exists',
|
||||
value: null,
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
fieldName: 'host.name',
|
||||
isNested: false,
|
||||
operator: null,
|
||||
value: null,
|
||||
operator: undefined,
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
fieldName: 'host.name.host.name',
|
||||
|
@ -407,4 +409,36 @@ describe('Exception helpers', () => {
|
|||
expect(wrapper.text()).toEqual('some old comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filterExceptionItems', () => {
|
||||
test('it removes empty entry items', () => {
|
||||
const { entries, ...rest } = getExceptionListItemSchemaMock();
|
||||
const mockEmptyException: EmptyEntry = {
|
||||
field: 'host.name',
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: OperatorEnum.INCLUDED,
|
||||
value: undefined,
|
||||
};
|
||||
const exceptions = filterExceptionItems([
|
||||
{
|
||||
...rest,
|
||||
entries: [...entries, mockEmptyException],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(exceptions).toEqual([getExceptionListItemSchemaMock()]);
|
||||
});
|
||||
|
||||
test('it removes `temporaryId` from items', () => {
|
||||
const { meta, ...rest } = getNewExceptionItem({
|
||||
listType: 'detection',
|
||||
listId: '123',
|
||||
namespaceType: 'single',
|
||||
ruleName: 'rule name',
|
||||
});
|
||||
const exceptions = filterExceptionItems([{ ...rest, meta }]);
|
||||
|
||||
expect(exceptions).toEqual([{ ...rest, meta: undefined }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,28 +8,44 @@ import React from 'react';
|
|||
import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui';
|
||||
import { capitalize } from 'lodash';
|
||||
import moment from 'moment';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { FormattedEntry, OperatorOption, DescriptionListItem } from './types';
|
||||
import { EXCEPTION_OPERATORS, isOperator } from './operators';
|
||||
import {
|
||||
FormattedEntry,
|
||||
BuilderEntry,
|
||||
EmptyListEntry,
|
||||
DescriptionListItem,
|
||||
FormattedBuilderEntry,
|
||||
CreateExceptionListItemBuilderSchema,
|
||||
ExceptionsBuilderExceptionItem,
|
||||
} from './types';
|
||||
import { EXCEPTION_OPERATORS, isOperator } from '../autocomplete/operators';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
import {
|
||||
CommentsArray,
|
||||
Entry,
|
||||
EntriesArray,
|
||||
ExceptionListItemSchema,
|
||||
NamespaceType,
|
||||
OperatorTypeEnum,
|
||||
CreateExceptionListItemSchema,
|
||||
entry,
|
||||
entriesNested,
|
||||
entriesExists,
|
||||
entriesList,
|
||||
createExceptionListItemSchema,
|
||||
exceptionListItemSchema,
|
||||
} from '../../../lists_plugin_deps';
|
||||
import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common';
|
||||
|
||||
export const isListType = (item: BuilderEntry): item is EmptyListEntry =>
|
||||
item.type === OperatorTypeEnum.LIST;
|
||||
|
||||
/**
|
||||
* Returns the operator type, may not need this if using io-ts types
|
||||
*
|
||||
* @param entry a single ExceptionItem entry
|
||||
* @param item a single ExceptionItem entry
|
||||
*/
|
||||
export const getOperatorType = (entry: Entry): OperatorTypeEnum => {
|
||||
switch (entry.type) {
|
||||
export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => {
|
||||
switch (item.type) {
|
||||
case 'match':
|
||||
return OperatorTypeEnum.MATCH;
|
||||
case 'match_any':
|
||||
|
@ -45,36 +61,46 @@ export const getOperatorType = (entry: Entry): OperatorTypeEnum => {
|
|||
* Determines operator selection (is/is not/is one of, etc.)
|
||||
* Default operator is "is"
|
||||
*
|
||||
* @param entry a single ExceptionItem entry
|
||||
* @param item a single ExceptionItem entry
|
||||
*/
|
||||
export const getExceptionOperatorSelect = (entry: Entry): OperatorOption => {
|
||||
if (entriesNested.is(entry)) {
|
||||
export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => {
|
||||
if (entriesNested.is(item)) {
|
||||
return isOperator;
|
||||
} else {
|
||||
const operatorType = getOperatorType(entry);
|
||||
const operatorType = getOperatorType(item);
|
||||
const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => {
|
||||
return entry.operator === operatorOption.operator && operatorType === operatorOption.type;
|
||||
return item.operator === operatorOption.operator && operatorType === operatorOption.type;
|
||||
});
|
||||
|
||||
return foundOperator ?? isOperator;
|
||||
}
|
||||
};
|
||||
|
||||
export const getExceptionOperatorFromSelect = (value: string): OperatorOption => {
|
||||
const operator = EXCEPTION_OPERATORS.filter(({ message }) => message === value);
|
||||
return operator[0] ?? isOperator;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats ExceptionItem entries into simple field, operator, value
|
||||
* for use in rendering items in table
|
||||
*
|
||||
* @param entries an ExceptionItem's entries
|
||||
*/
|
||||
export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] => {
|
||||
const formattedEntries = entries.map((entry) => {
|
||||
if (entriesNested.is(entry)) {
|
||||
const parent = { fieldName: entry.field, operator: null, value: null, isNested: false };
|
||||
return entry.entries.reduce<FormattedEntry[]>(
|
||||
export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => {
|
||||
const formattedEntries = entries.map((item) => {
|
||||
if (entriesNested.is(item)) {
|
||||
const parent = {
|
||||
fieldName: item.field,
|
||||
operator: undefined,
|
||||
value: undefined,
|
||||
isNested: false,
|
||||
};
|
||||
return item.entries.reduce<FormattedEntry[]>(
|
||||
(acc, nestedEntry) => {
|
||||
const formattedEntry = formatEntry({
|
||||
isNested: true,
|
||||
parent: entry.field,
|
||||
parent: item.field,
|
||||
item: nestedEntry,
|
||||
});
|
||||
return [...acc, { ...formattedEntry }];
|
||||
|
@ -82,20 +108,24 @@ export const getFormattedEntries = (entries: EntriesArray): FormattedEntry[] =>
|
|||
[parent]
|
||||
);
|
||||
} else {
|
||||
return formatEntry({ isNested: false, item: entry });
|
||||
return formatEntry({ isNested: false, item });
|
||||
}
|
||||
});
|
||||
|
||||
return formattedEntries.flat();
|
||||
};
|
||||
|
||||
export const getEntryValue = (entry: Entry): string | string[] | null => {
|
||||
if (entriesList.is(entry)) {
|
||||
return entry.list.id;
|
||||
} else if (entriesExists.is(entry)) {
|
||||
return null;
|
||||
} else {
|
||||
return entry.value;
|
||||
export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => {
|
||||
switch (item.type) {
|
||||
case OperatorTypeEnum.MATCH:
|
||||
case OperatorTypeEnum.MATCH_ANY:
|
||||
return item.value;
|
||||
case OperatorTypeEnum.EXISTS:
|
||||
return undefined;
|
||||
case OperatorTypeEnum.LIST:
|
||||
return item.list.id;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -109,13 +139,13 @@ export const formatEntry = ({
|
|||
}: {
|
||||
isNested: boolean;
|
||||
parent?: string;
|
||||
item: Entry;
|
||||
item: BuilderEntry;
|
||||
}): FormattedEntry => {
|
||||
const operator = getExceptionOperatorSelect(item);
|
||||
const value = getEntryValue(item);
|
||||
|
||||
return {
|
||||
fieldName: isNested ? `${parent}.${item.field}` : item.field,
|
||||
fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '',
|
||||
operator: operator.message,
|
||||
value,
|
||||
isNested,
|
||||
|
@ -192,3 +222,122 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[]
|
|||
timelineIcon: <EuiAvatar size="l" name={comment.created_by.toUpperCase()} />,
|
||||
children: <EuiText size="s">{comment.comment}</EuiText>,
|
||||
}));
|
||||
|
||||
export const getFormattedBuilderEntries = (
|
||||
indexPattern: IIndexPattern,
|
||||
entries: BuilderEntry[]
|
||||
): FormattedBuilderEntry[] => {
|
||||
const { fields } = indexPattern;
|
||||
return entries.map((item) => {
|
||||
if (entriesNested.is(item)) {
|
||||
return {
|
||||
parent: item.field,
|
||||
operator: isOperator,
|
||||
nested: getFormattedBuilderEntries(indexPattern, item.entries),
|
||||
field: undefined,
|
||||
value: undefined,
|
||||
};
|
||||
} else {
|
||||
const [selectedField] = fields.filter(
|
||||
({ name }) => item.field != null && item.field === name
|
||||
);
|
||||
return {
|
||||
field: selectedField,
|
||||
operator: getExceptionOperatorSelect(item),
|
||||
value: getEntryValue(item),
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const getValueFromOperator = (
|
||||
field: IFieldType | undefined,
|
||||
selectedOperator: OperatorOption
|
||||
): Entry => {
|
||||
const fieldValue = field != null ? field.name : '';
|
||||
switch (selectedOperator.type) {
|
||||
case 'match':
|
||||
return {
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.MATCH,
|
||||
operator: selectedOperator.operator,
|
||||
value: '',
|
||||
};
|
||||
case 'match_any':
|
||||
return {
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.MATCH_ANY,
|
||||
operator: selectedOperator.operator,
|
||||
value: [],
|
||||
};
|
||||
case 'list':
|
||||
return {
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.LIST,
|
||||
operator: selectedOperator.operator,
|
||||
list: { id: '', type: 'ip' },
|
||||
};
|
||||
default:
|
||||
return {
|
||||
field: fieldValue,
|
||||
type: OperatorTypeEnum.EXISTS,
|
||||
operator: selectedOperator.operator,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getNewExceptionItem = ({
|
||||
listType,
|
||||
listId,
|
||||
namespaceType,
|
||||
ruleName,
|
||||
}: {
|
||||
listType: 'detection' | 'endpoint';
|
||||
listId: string;
|
||||
namespaceType: NamespaceType;
|
||||
ruleName: string;
|
||||
}): CreateExceptionListItemBuilderSchema => {
|
||||
return {
|
||||
_tags: [listType],
|
||||
comments: [],
|
||||
description: `${ruleName} - exception list item`,
|
||||
entries: [
|
||||
{
|
||||
field: '',
|
||||
operator: 'included',
|
||||
type: 'match',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
item_id: undefined,
|
||||
list_id: listId,
|
||||
meta: {
|
||||
temporaryUuid: uuid.v4(),
|
||||
},
|
||||
name: `${ruleName} - exception list item`,
|
||||
namespace_type: namespaceType,
|
||||
tags: [],
|
||||
type: 'simple',
|
||||
};
|
||||
};
|
||||
|
||||
export const filterExceptionItems = (
|
||||
exceptions: ExceptionsBuilderExceptionItem[]
|
||||
): Array<ExceptionListItemSchema | CreateExceptionListItemSchema> => {
|
||||
return exceptions.reduce<Array<ExceptionListItemSchema | CreateExceptionListItemSchema>>(
|
||||
(acc, exception) => {
|
||||
const entries = exception.entries.filter((t) => entry.is(t) || entriesNested.is(t));
|
||||
const item = { ...exception, entries };
|
||||
if (exceptionListItemSchema.is(item)) {
|
||||
return [...acc, item];
|
||||
} else if (createExceptionListItemSchema.is(item) && item.meta != null) {
|
||||
const { meta, ...rest } = item;
|
||||
const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined };
|
||||
return [...acc, itemSansMetaId];
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const DETECTION_LIST = i18n.translate(
|
||||
|
@ -137,3 +138,65 @@ export const SHOWING_EXCEPTIONS = (items: number) =>
|
|||
values: { items },
|
||||
defaultMessage: 'Showing {items} {items, plural, =1 {exception} other {exceptions}}',
|
||||
});
|
||||
|
||||
export const FIELD = i18n.translate('xpack.securitySolution.exceptions.fieldDescription', {
|
||||
defaultMessage: 'Field',
|
||||
});
|
||||
|
||||
export const OPERATOR = i18n.translate('xpack.securitySolution.exceptions.operatorDescription', {
|
||||
defaultMessage: 'Operator',
|
||||
});
|
||||
|
||||
export const VALUE = i18n.translate('xpack.securitySolution.exceptions.valueDescription', {
|
||||
defaultMessage: 'Value',
|
||||
});
|
||||
|
||||
export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.exceptionFieldPlaceholderDescription',
|
||||
{
|
||||
defaultMessage: 'Search',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.exceptionOperatorPlaceholderDescription',
|
||||
{
|
||||
defaultMessage: 'Operator',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.exceptionFieldValuePlaceholderDescription',
|
||||
{
|
||||
defaultMessage: 'Search field value...',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.exceptionListsPlaceholderDescription',
|
||||
{
|
||||
defaultMessage: 'Search for list...',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADD_EXCEPTION_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.addExceptionTitle',
|
||||
{
|
||||
defaultMessage: 'Add exception',
|
||||
}
|
||||
);
|
||||
|
||||
export const AND = i18n.translate('xpack.securitySolution.exceptions.andDescription', {
|
||||
defaultMessage: 'AND',
|
||||
});
|
||||
|
||||
export const OR = i18n.translate('xpack.securitySolution.exceptions.orDescription', {
|
||||
defaultMessage: 'OR',
|
||||
});
|
||||
|
||||
export const ADD_NESTED_DESCRIPTION = i18n.translate(
|
||||
'xpack.securitySolution.exceptions.addNestedDescription',
|
||||
{
|
||||
defaultMessage: 'Add nested condition',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -4,20 +4,21 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Operator, OperatorType } from '../../../lists_plugin_deps';
|
||||
|
||||
export interface OperatorOption {
|
||||
message: string;
|
||||
value: string;
|
||||
operator: Operator;
|
||||
type: OperatorType;
|
||||
}
|
||||
import { IFieldType } from '../../../../../../../src/plugins/data/common';
|
||||
import { OperatorOption } from '../autocomplete/types';
|
||||
import {
|
||||
EntryNested,
|
||||
Entry,
|
||||
ExceptionListItemSchema,
|
||||
CreateExceptionListItemSchema,
|
||||
OperatorTypeEnum,
|
||||
OperatorEnum,
|
||||
} from '../../../lists_plugin_deps';
|
||||
|
||||
export interface FormattedEntry {
|
||||
fieldName: string;
|
||||
operator: string | null;
|
||||
value: string | string[] | null;
|
||||
operator: string | undefined;
|
||||
value: string | string[] | undefined;
|
||||
isNested: boolean;
|
||||
}
|
||||
|
||||
|
@ -49,3 +50,46 @@ export interface ExceptionsPagination {
|
|||
totalItemCount: number;
|
||||
pageSizeOptions: number[];
|
||||
}
|
||||
|
||||
export interface FormattedBuilderEntryBase {
|
||||
field: IFieldType | undefined;
|
||||
operator: OperatorOption;
|
||||
value: string | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface FormattedBuilderEntry extends FormattedBuilderEntryBase {
|
||||
parent?: string;
|
||||
nested?: FormattedBuilderEntryBase[];
|
||||
}
|
||||
|
||||
export interface EmptyEntry {
|
||||
field: string | undefined;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum.MATCH | OperatorTypeEnum.MATCH_ANY;
|
||||
value: string | string[] | undefined;
|
||||
}
|
||||
|
||||
export interface EmptyListEntry {
|
||||
field: string | undefined;
|
||||
operator: OperatorEnum;
|
||||
type: OperatorTypeEnum.LIST;
|
||||
list: { id: string | undefined; type: string | undefined };
|
||||
}
|
||||
|
||||
export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested;
|
||||
|
||||
export type ExceptionListItemBuilderSchema = Omit<ExceptionListItemSchema, 'entries'> & {
|
||||
entries: BuilderEntry[];
|
||||
};
|
||||
|
||||
export type CreateExceptionListItemBuilderSchema = Omit<
|
||||
CreateExceptionListItemSchema,
|
||||
'meta' | 'entries'
|
||||
> & {
|
||||
meta: { temporaryUuid: string };
|
||||
entries: BuilderEntry[];
|
||||
};
|
||||
|
||||
export type ExceptionsBuilderExceptionItem =
|
||||
| ExceptionListItemBuilderSchema
|
||||
| CreateExceptionListItemBuilderSchema;
|
||||
|
|
|
@ -115,8 +115,8 @@ describe('ExceptionEntries', () => {
|
|||
|
||||
test('it renders nested entry', () => {
|
||||
const parentEntry = getFormattedEntryMock();
|
||||
parentEntry.operator = null;
|
||||
parentEntry.value = null;
|
||||
parentEntry.operator = undefined;
|
||||
parentEntry.value = undefined;
|
||||
|
||||
const wrapper = mount(
|
||||
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
|
||||
|
|
|
@ -9,23 +9,32 @@ export {
|
|||
useExceptionList,
|
||||
usePersistExceptionItem,
|
||||
usePersistExceptionList,
|
||||
useFindLists,
|
||||
ExceptionIdentifiers,
|
||||
ExceptionList,
|
||||
Pagination,
|
||||
UseExceptionListSuccess,
|
||||
} from '../../lists/public';
|
||||
export {
|
||||
ListSchema,
|
||||
CommentsArray,
|
||||
ExceptionListSchema,
|
||||
ExceptionListItemSchema,
|
||||
CreateExceptionListItemSchema,
|
||||
Entry,
|
||||
EntryExists,
|
||||
EntryNested,
|
||||
EntryList,
|
||||
EntriesArray,
|
||||
NamespaceType,
|
||||
Operator,
|
||||
OperatorEnum,
|
||||
OperatorType,
|
||||
OperatorTypeEnum,
|
||||
exceptionListItemSchema,
|
||||
createExceptionListItemSchema,
|
||||
listSchema,
|
||||
entry,
|
||||
entriesNested,
|
||||
entriesExists,
|
||||
entriesList,
|
||||
|
|
Loading…
Reference in a new issue