[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:
Yara Tercero 2020-07-01 20:33:57 -04:00 committed by GitHub
parent 4f7da59a51
commit 6581450449
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 3623 additions and 980 deletions

View file

@ -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);
});
});
});

View file

@ -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,

View file

@ -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>
);

View file

@ -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',
},
]);
});
});

View file

@ -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';

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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',
});
});
});

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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';

View file

@ -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',
},
],
});
});
});
});

View file

@ -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,
};
}

View file

@ -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);
});
});
});

View file

@ -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];
};

View file

@ -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' },
]);
});
});

View file

@ -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';

View file

@ -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[] = [

View file

@ -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}
/>
```

View file

@ -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...',
});

View file

@ -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;
}

View file

@ -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>
));

View file

@ -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>
`;

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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;
}
};

View file

@ -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')}
/>
);
});

View file

@ -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);
});
});

View file

@ -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>
);

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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 }]);
});
});
});

View file

@ -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;
}
},
[]
);
};

View file

@ -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',
}
);

View file

@ -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;

View file

@ -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 })}>

View file

@ -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,