[Security Solution][Exceptions] - Moves ExceptionItem component to lists plugin (#95246)

## Summary

Moves part of the exceptions UI out of the security solution plugin and into the lists plugin. In order to keep PRs (relatively) small, I am moving single components at a time. This should also then help more easily pinpoint the source of any issues that come up along the way.

The next couple PRs will focus on the exception builder. This one in particular is focused on moving over the `BuilderExceptionItem` which deals with rendering the individual exception items.
This commit is contained in:
Yara Tercero 2021-03-26 20:59:49 -07:00 committed by GitHub
parent 80fdcde813
commit 17d3907730
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1271 additions and 826 deletions

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { RecursivePartial } from '@elastic/eui/src/components/common';
import { EuiTheme } from '../../../../../../src/plugins/kibana_react/common';
export const getMockTheme = (partialTheme: RecursivePartial<EuiTheme>): EuiTheme =>
partialTheme as EuiTheme;

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Story, addDecorator } from '@storybook/react';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { AndOrBadge, AndOrBadgeProps } from '.';
const sampleText =
'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.';
const mockTheme = getMockTheme({
darkMode: false,
eui: euiLightVars,
});
addDecorator((storyFn) => <ThemeProvider theme={mockTheme}>{storyFn()}</ThemeProvider>);
export default {
argTypes: {
includeAntennas: {
control: {
type: 'boolean',
},
description: 'Determines whether extending vertical lines appear extended off of round badge',
table: {
defaultValue: {
summary: false,
},
},
type: {
required: false,
},
},
type: {
control: {
options: ['and', 'or'],
type: 'select',
},
description: '`and | or` - determines text displayed in badge.',
table: {
defaultValue: {
summary: 'and',
},
},
type: {
required: true,
},
},
},
component: AndOrBadge,
title: 'AndOrBadge',
};
const AndOrBadgeTemplate: Story<AndOrBadgeProps> = (args) => (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<AndOrBadge {...args} />
</EuiFlexItem>
<EuiFlexItem>
<p>{sampleText}</p>
</EuiFlexItem>
</EuiFlexGroup>
);
export const Default = AndOrBadgeTemplate.bind({});
Default.args = {
includeAntennas: false,
type: 'and',
};

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { AndOrBadge } from './';
const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } });
describe('AndOrBadge', () => {
test('it renders top and bottom antenna bars when "includeAntennas" is true', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<AndOrBadge includeAntennas type="and" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND');
expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy();
});
test('it does not render top and bottom antenna bars when "includeAntennas" is false', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<AndOrBadge type="or" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR');
expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeFalsy();
});
test('it renders "and" when "type" is "and"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<AndOrBadge type="and" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND');
});
test('it renders "or" when "type" is "or"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<AndOrBadge type="or" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR');
});
});

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { RoundedBadge } from './rounded_badge';
import { RoundedBadgeAntenna } from './rounded_badge_antenna';
export type AndOr = 'and' | 'or';
export interface AndOrBadgeProps {
type: AndOr;
includeAntennas?: boolean;
}
/** Displays AND / OR in a round badge */
// This ticket is closed, however, as of 3/23/21 no round badge yet
// Ref: https://github.com/elastic/eui/issues/1655
export const AndOrBadge = React.memo<AndOrBadgeProps>(({ type, includeAntennas = false }) => {
return includeAntennas ? <RoundedBadgeAntenna type={type} /> : <RoundedBadge type={type} />;
});
AndOrBadge.displayName = 'AndOrBadge';

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { RoundedBadge } from './rounded_badge';
describe('RoundedBadge', () => {
test('it renders "and" when "type" is "and"', () => {
const wrapper = mount(<RoundedBadge type="and" />);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND');
});
test('it renders "or" when "type" is "or"', () => {
const wrapper = mount(<RoundedBadge type="or" />);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR');
});
});

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiBadge } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
import * as i18n from './translations';
import { AndOr } from '.';
const RoundBadge = (styled(EuiBadge)`
align-items: center;
border-radius: 100%;
display: inline-flex;
font-size: 9px;
height: 34px;
justify-content: center;
margin: 0 5px 0 5px;
padding: 7px 6px 4px 6px;
user-select: none;
width: 34px;
.euiBadge__content {
position: relative;
top: -1px;
}
.euiBadge__text {
text-overflow: clip;
}
` as unknown) as typeof EuiBadge;
RoundBadge.displayName = 'RoundBadge';
export const RoundedBadge: React.FC<{ type: AndOr }> = ({ type }) => (
<RoundBadge data-test-subj="and-or-badge" color="hollow">
{type === 'and' ? i18n.AND : i18n.OR}
</RoundBadge>
);
RoundedBadge.displayName = 'RoundedBadge';

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { RoundedBadgeAntenna } from './rounded_badge_antenna';
const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } });
describe('RoundedBadgeAntenna', () => {
test('it renders top and bottom antenna bars', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<RoundedBadgeAntenna type="and" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND');
expect(wrapper.find('[data-test-subj="andOrBadgeBarTop"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="andOrBadgeBarBottom"]').exists()).toBeTruthy();
});
test('it renders "and" when "type" is "and"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<RoundedBadgeAntenna type="and" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND');
});
test('it renders "or" when "type" is "or"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<RoundedBadgeAntenna type="or" />
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR');
});
});

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import styled, { css } from 'styled-components';
import { RoundedBadge } from './rounded_badge';
import { AndOr } from '.';
const antennaStyles = css`
background: ${({ theme }): string => theme.eui.euiColorLightShade};
position: relative;
width: 2px;
&:after {
background: ${({ theme }): string => theme.eui.euiColorLightShade};
content: '';
height: 8px;
right: -4px;
position: absolute;
width: 10px;
clip-path: circle();
}
`;
const TopAntenna = styled(EuiFlexItem)`
${antennaStyles}
&:after {
top: 0;
}
`;
const BottomAntenna = styled(EuiFlexItem)`
${antennaStyles}
&:after {
bottom: 0;
}
`;
export const RoundedBadgeAntenna: React.FC<{ type: AndOr }> = ({ type }) => (
<EuiFlexGroup
className="andBadgeContainer"
gutterSize="none"
direction="column"
alignItems="center"
>
<TopAntenna data-test-subj="andOrBadgeBarTop" grow={1} />
<EuiFlexItem grow={false}>
<RoundedBadge type={type} />
</EuiFlexItem>
<BottomAntenna data-test-subj="andOrBadgeBarBottom" grow={1} />
</EuiFlexGroup>
);
RoundedBadgeAntenna.displayName = 'RoundedBadgeAntenna';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const AND = i18n.translate('xpack.lists.andOrBadge.andLabel', {
defaultMessage: 'AND',
});
export const OR = i18n.translate('xpack.lists.andOrBadge.orLabel', {
defaultMessage: 'OR',
});

View file

@ -9,8 +9,9 @@ import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { BuilderAndBadgeComponent } from './and_badge';
import { getMockTheme } from '../../../lib/kibana/kibana_react.mock';
const mockTheme = getMockTheme({ eui: { euiColorLightShade: '#ece' } });

View file

@ -9,7 +9,7 @@ import React from 'react';
import { EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { AndOrBadge } from '../../and_or_badge';
import { AndOrBadge } from '../and_or_badge';
const MyInvisibleAndBadge = styled(EuiFlexItem)`
visibility: hidden;

View file

@ -8,8 +8,8 @@
import { mount } from 'enzyme';
import React from 'react';
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock';
import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock';
import { BuilderEntryDeleteButtonComponent } from './entry_delete_button';

View file

@ -9,7 +9,7 @@ import React, { useCallback } from 'react';
import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { BuilderEntry } from '../types';
import { BuilderEntry } from './types';
const MyFirstRowContainer = styled(EuiFlexItem)`
padding-top: 20px;

View file

@ -15,10 +15,11 @@ import { HttpStart } from 'kibana/public';
import { OperatorEnum, OperatorTypeEnum } from '../../../../common';
import { AutocompleteStart } from '../../../../../../../src/plugins/data/public';
import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { BuilderEntryItem, EntryItemProps } from './entry_renderer';
const mockTheme = (): { darkMode: boolean; eui: unknown } => ({
const mockTheme = getMockTheme({
darkMode: false,
eui: euiLightVars,
});

View file

@ -8,39 +8,28 @@
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { dataPluginMock } from 'src/plugins/data/public/mocks';
import { useKibana } from '../../../../common/lib/kibana';
import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock';
import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
import { fields } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchMock } from '../../../../common/schemas/types/entry_match.mock';
import { getEntryMatchAnyMock } from '../../../../common/schemas/types/entry_match_any.mock';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { getMockTheme } from '../../../common/test_utils/kibana_react.mock';
import { BuilderExceptionListItemComponent } from './exception_item';
import { getMockTheme } from '../../../lib/kibana/kibana_react.mock';
import { BuilderExceptionListItemComponent } from './exception_item_renderer';
const mockTheme = getMockTheme({
eui: {
euiColorLightShade: '#ece',
},
});
jest.mock('../../../../common/lib/kibana');
const mockKibanaHttpService = coreMock.createStart().http;
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
describe('BuilderExceptionListItemComponent', () => {
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']);
beforeAll(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
autocomplete: {
getValueSuggestions: getValueSuggestionsMock,
},
},
},
});
});
afterEach(() => {
getValueSuggestionsMock.mockClear();
});
@ -54,19 +43,22 @@ describe('BuilderExceptionListItemComponent', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={true}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={0}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={true}
isOnlyItem={false}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
</ThemeProvider>
);
@ -82,19 +74,22 @@ describe('BuilderExceptionListItemComponent', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={true}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={1}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={true}
isOnlyItem={false}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
</ThemeProvider>
);
@ -108,19 +103,22 @@ describe('BuilderExceptionListItemComponent', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={true}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={1}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={true}
isOnlyItem={false}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
</ThemeProvider>
);
@ -136,19 +134,22 @@ describe('BuilderExceptionListItemComponent', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={1}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
isOnlyItem={false}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
</ThemeProvider>
);
@ -171,19 +172,22 @@ describe('BuilderExceptionListItemComponent', () => {
};
const wrapper = mount(
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={0}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
isOnlyItem={true}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
);
@ -198,19 +202,22 @@ describe('BuilderExceptionListItemComponent', () => {
const wrapper = mount(
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={0}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
isOnlyItem={false}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
);
@ -224,21 +231,24 @@ describe('BuilderExceptionListItemComponent', () => {
exceptionItem.entries = [getEntryMatchMock()];
const wrapper = mount(
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={1}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
// if exceptionItemIndex is not 0, wouldn't make sense for
// this to be true, but done for testing purposes
isOnlyItem={true}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
);
@ -252,19 +262,22 @@ describe('BuilderExceptionListItemComponent', () => {
exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()];
const wrapper = mount(
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={0}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
isOnlyItem={true}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={jest.fn()}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={jest.fn()}
setErrorsExist={jest.fn()}
/>
);
@ -280,19 +293,22 @@ describe('BuilderExceptionListItemComponent', () => {
exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()];
const wrapper = mount(
<BuilderExceptionListItemComponent
allowLargeValueLists={true}
andLogicIncluded={false}
autocompleteService={autocompleteStartMock}
exceptionItem={exceptionItem}
exceptionItemIndex={0}
httpService={mockKibanaHttpService}
indexPattern={{
fields,
id: '1234',
title: 'logstash-*',
fields,
}}
andLogicIncluded={false}
isOnlyItem={true}
listType="detection"
setErrorsExist={jest.fn()}
onDeleteExceptionItem={mockOnDeleteExceptionItem}
onChangeExceptionItem={jest.fn()}
onDeleteExceptionItem={mockOnDeleteExceptionItem}
setErrorsExist={jest.fn()}
/>
);

View file

@ -5,23 +5,24 @@
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { HttpStart } from 'kibana/public';
import { AutocompleteStart } from 'src/plugins/data/public';
import { Type } from '../../../../../common/detection_engine/schemas/common/schemas';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers';
import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types';
import { BuilderEntryItem, ExceptionListType } from '../../../../shared_imports';
import { BuilderEntryDeleteButtonComponent } from './entry_delete_button';
import { ExceptionListType } from '../../../../common';
import { IIndexPattern } from '../../../../../../../src/plugins/data/common';
import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from './types';
import { BuilderAndBadgeComponent } from './and_badge';
import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils';
import { useKibana } from '../../../lib/kibana';
import { BuilderEntryDeleteButtonComponent } from './entry_delete_button';
import { BuilderEntryItem } from './entry_renderer';
import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers';
const MyBeautifulLine = styled(EuiFlexItem)`
&:after {
background: ${({ theme }) => theme.eui.euiColorLightShade};
background: ${({ theme }): string => theme.eui.euiColorLightShade};
content: '';
width: 2px;
height: 40px;
@ -35,6 +36,9 @@ const MyOverflowContainer = styled(EuiFlexItem)`
`;
interface BuilderExceptionListItemProps {
allowLargeValueLists: boolean;
httpService: HttpStart;
autocompleteService: AutocompleteStart;
exceptionItem: ExceptionsBuilderExceptionItem;
exceptionItemIndex: number;
indexPattern: IIndexPattern;
@ -45,11 +49,13 @@ interface BuilderExceptionListItemProps {
onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void;
setErrorsExist: (arg: boolean) => void;
onlyShowListOperators?: boolean;
ruleType?: Type;
}
export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionListItemProps>(
({
allowLargeValueLists,
httpService,
autocompleteService,
exceptionItem,
exceptionItemIndex,
indexPattern,
@ -60,9 +66,7 @@ export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionList
onChangeExceptionItem,
setErrorsExist,
onlyShowListOperators = false,
ruleType,
}) => {
const { http, data } = useKibana().services;
const handleEntryChange = useCallback(
(entry: BuilderEntry, entryIndex: number): void => {
const updatedEntries: BuilderEntry[] = [
@ -119,9 +123,9 @@ export const BuilderExceptionListItemComponent = React.memo<BuilderExceptionList
{item.nested === 'child' && <MyBeautifulLine grow={false} />}
<MyOverflowContainer grow={1}>
<BuilderEntryItem
allowLargeValueLists={!isEqlRule(ruleType) && !isThresholdRule(ruleType)}
httpService={http}
autocompleteService={data.autocomplete}
allowLargeValueLists={allowLargeValueLists}
httpService={httpService}
autocompleteService={autocompleteService}
entry={item}
indexPattern={indexPattern}
listType={listType}

View file

@ -5,6 +5,9 @@
* 2.0.
*/
import { ENTRIES_WITH_IDS } from '../../../../common/constants.mock';
import { getEntryExistsMock } from '../../../../common/schemas/types/entry_exists.mock';
import { getExceptionListItemSchemaMock } from '../../../../common/schemas/response/exception_list_item_schema.mock';
import {
fields,
getField,
@ -37,8 +40,9 @@ import {
} from '../../../../common';
import { OperatorOption } from '../autocomplete/types';
import { BuilderEntry, FormattedBuilderEntry } from './types';
import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from './types';
import {
getCorrespondingKeywordField,
getEntryFromOperator,
getEntryOnFieldChange,
getEntryOnListChange,
@ -46,13 +50,22 @@ import {
getEntryOnMatchChange,
getEntryOnOperatorChange,
getFilteredIndexPatterns,
getFormattedBuilderEntries,
getFormattedBuilderEntry,
getOperatorOptions,
getUpdatedEntriesOnDelete,
isEntryNested,
} from './helpers';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
const getEntryExistsWithIdMock = (): EntryExists & { id: string } => ({
...getEntryExistsMock(),
id: '123',
});
const getEntryNestedWithIdMock = (): EntryNested & { id: string } => ({
...getEntryNestedMock(),
id: '123',
@ -995,4 +1008,422 @@ describe('Exception builder helpers', () => {
expect(output).toEqual(expected);
});
});
describe('#getFormattedBuilderEntries', () => {
test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
correspondingKeywordField: undefined,
entryIndex: 0,
field: undefined,
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some host name',
},
];
expect(output).toEqual(expected);
});
test('it returns formatted entries when no nested entries exist', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItems: BuilderEntry[] = [
{ ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' },
{ ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] },
];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
correspondingKeywordField: undefined,
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
},
{
correspondingKeywordField: undefined,
entryIndex: 1,
field: {
aggregatable: true,
count: 0,
esTypes: ['keyword'],
name: 'extension',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'string',
},
id: '123',
nested: undefined,
operator: isOneOfOperator,
parent: undefined,
value: ['some extension'],
},
];
expect(output).toEqual(expected);
});
test('it returns formatted entries when nested entries exist', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadParent: EntryNested = {
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
field: 'nestedField',
};
const payloadItems: BuilderEntry[] = [
{ ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' },
{ ...payloadParent },
];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
correspondingKeywordField: undefined,
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
},
{
correspondingKeywordField: undefined,
entryIndex: 1,
field: {
aggregatable: false,
esTypes: ['nested'],
name: 'nestedField',
searchable: false,
type: 'string',
},
id: '123',
nested: 'parent',
operator: isOperator,
parent: undefined,
value: undefined,
},
{
correspondingKeywordField: undefined,
entryIndex: 0,
field: {
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'child',
readFromDocValues: false,
scripted: false,
searchable: true,
subType: {
nested: {
path: 'nestedField',
},
},
type: 'string',
},
id: '123',
nested: 'child',
operator: isOperator,
parent: {
parent: {
entries: [
{
field: 'child',
id: '123',
operator: OperatorEnum.INCLUDED,
type: OperatorTypeEnum.MATCH,
value: 'some host name',
},
],
field: 'nestedField',
id: '123',
type: OperatorTypeEnum.NESTED,
},
parentIndex: 1,
},
value: 'some host name',
},
];
expect(output).toEqual(expected);
});
});
describe('#getUpdatedEntriesOnDelete', () => {
test('it removes entry corresponding to "entryIndex"', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: ENTRIES_WITH_IDS,
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, null);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
field: 'some.not.nested.field',
id: '123',
operator: OperatorEnum.INCLUDED,
type: OperatorTypeEnum.MATCH,
value: 'some value',
},
],
};
expect(output).toEqual(expected);
});
test('it removes nested entry of "entryIndex" with corresponding parent index', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }],
},
],
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{ ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] },
],
};
expect(output).toEqual(expected);
});
test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryExistsWithIdMock() }],
},
],
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [],
};
expect(output).toEqual(expected);
});
});
describe('#getFormattedBuilderEntry', () => {
test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => {
const payloadIndexPattern: IIndexPattern = {
...getMockIndexPattern(),
fields: [
...fields,
{
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'machine.os.raw.text',
readFromDocValues: true,
scripted: false,
searchable: false,
type: 'string',
},
],
};
const payloadItem: BuilderEntry = {
...getEntryMatchWithIdMock(),
field: 'machine.os.raw.text',
value: 'some os',
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
undefined,
undefined
);
const expected: FormattedBuilderEntry = {
correspondingKeywordField: getField('machine.os.raw'),
entryIndex: 0,
field: {
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'machine.os.raw.text',
readFromDocValues: true,
scripted: false,
searchable: false,
type: 'string',
},
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some os',
};
expect(output).toEqual(expected);
});
test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' };
const payloadParent: EntryNested = {
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
field: 'nestedField',
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
payloadParent,
1
);
const expected: FormattedBuilderEntry = {
correspondingKeywordField: undefined,
entryIndex: 0,
field: {
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'child',
readFromDocValues: false,
scripted: false,
searchable: true,
subType: {
nested: {
path: 'nestedField',
},
},
type: 'string',
},
id: '123',
nested: 'child',
operator: isOperator,
parent: {
parent: {
entries: [{ ...payloadItem }],
field: 'nestedField',
id: '123',
type: OperatorTypeEnum.NESTED,
},
parentIndex: 1,
},
value: 'some host name',
};
expect(output).toEqual(expected);
});
test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItem: BuilderEntry = {
...getEntryMatchWithIdMock(),
field: 'ip',
value: 'some ip',
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
undefined,
undefined
);
const expected: FormattedBuilderEntry = {
correspondingKeywordField: undefined,
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
id: '123',
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
};
expect(output).toEqual(expected);
});
});
describe('#isEntryNested', () => {
test('it returns "false" if payload is not of type EntryNested', () => {
const payload: BuilderEntry = getEntryMatchWithIdMock();
const output = isEntryNested(payload);
const expected = false;
expect(output).toEqual(expected);
});
test('it returns "true if payload is of type EntryNested', () => {
const payload: EntryNested = getEntryNestedWithIdMock();
const output = isEntryNested(payload);
const expected = true;
expect(output).toEqual(expected);
});
});
describe('#getCorrespondingKeywordField', () => {
test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: 'machine.os.raw.text',
});
expect(output).toEqual(getField('machine.os.raw'));
});
test('it returns undefined if "selectedFieldIsTextType" is false', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: 'machine.os.raw',
});
expect(output).toEqual(undefined);
});
test('it returns undefined if "selectedField" is empty string', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: '',
});
expect(output).toEqual(undefined);
});
test('it returns undefined if "selectedField" is undefined', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: undefined,
});
expect(output).toEqual(undefined);
});
});
});

View file

@ -9,6 +9,7 @@ import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data
import { addIdToItem } from '../../../../common/shared_imports';
import {
Entry,
EntryNested,
ExceptionListType,
ListSchema,
OperatorTypeEnum,
@ -25,7 +26,132 @@ import {
} from '../autocomplete/operators';
import { OperatorOption } from '../autocomplete/types';
import { BuilderEntry, FormattedBuilderEntry } from './types';
import {
BuilderEntry,
EmptyNestedEntry,
ExceptionsBuilderExceptionItem,
FormattedBuilderEntry,
} from './types';
export const isEntryNested = (item: BuilderEntry): item is EntryNested => {
return (item as EntryNested).entries != null;
};
/**
* Returns the operator type, may not need this if using io-ts types
*
* @param item a single ExceptionItem entry
*/
export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => {
switch (item.type) {
case 'match':
return OperatorTypeEnum.MATCH;
case 'match_any':
return OperatorTypeEnum.MATCH_ANY;
case 'list':
return OperatorTypeEnum.LIST;
default:
return OperatorTypeEnum.EXISTS;
}
};
/**
* Determines operator selection (is/is not/is one of, etc.)
* Default operator is "is"
*
* @param item a single ExceptionItem entry
*/
export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => {
if (item.type === 'nested') {
return isOperator;
} else {
const operatorType = getOperatorType(item);
const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => {
return item.operator === operatorOption.operator && operatorType === operatorOption.type;
});
return foundOperator ?? isOperator;
}
};
/**
* Returns the fields corresponding value for an entry
*
* @param item a single ExceptionItem entry
*/
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;
}
};
/**
* Determines whether an entire entry, exception item, or entry within a nested
* entry needs to be removed
*
* @param exceptionItem
* @param entryIndex index of given entry, for nested entries, this will correspond
* to their parent index
* @param nestedEntryIndex index of nested entry
*
*/
export const getUpdatedEntriesOnDelete = (
exceptionItem: ExceptionsBuilderExceptionItem,
entryIndex: number,
nestedParentIndex: number | null
): ExceptionsBuilderExceptionItem => {
const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex];
if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) {
const updatedEntryEntries = [
...itemOfInterest.entries.slice(0, entryIndex),
...itemOfInterest.entries.slice(entryIndex + 1),
];
if (updatedEntryEntries.length === 0) {
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, nestedParentIndex),
...exceptionItem.entries.slice(nestedParentIndex + 1),
],
};
} else {
const { field } = itemOfInterest;
const updatedItemOfInterest: EntryNested | EmptyNestedEntry = {
entries: updatedEntryEntries,
field,
id: itemOfInterest.id ?? `${entryIndex}`,
type: OperatorTypeEnum.NESTED,
};
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, nestedParentIndex),
updatedItemOfInterest,
...exceptionItem.entries.slice(nestedParentIndex + 1),
],
};
}
} else {
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, entryIndex),
...exceptionItem.entries.slice(entryIndex + 1),
],
};
}
};
/**
* Returns filtered index patterns based on the field - if a user selects to
@ -387,3 +513,155 @@ export const getOperatorOptions = (
: EXCEPTION_OPERATORS_SANS_LISTS;
}
};
/**
* Fields of type 'text' do not generate autocomplete values, we want
* to find it's corresponding keyword type (if available) which does
* generate autocomplete values
*
* @param fields IFieldType fields
* @param selectedField the field name that was selected
* @param isTextType we only want a corresponding keyword field if
* the selected field is of type 'text'
*
*/
export const getCorrespondingKeywordField = ({
fields,
selectedField,
}: {
fields: IFieldType[];
selectedField: string | undefined;
}): IFieldType | undefined => {
const selectedFieldBits =
selectedField != null && selectedField !== '' ? selectedField.split('.') : [];
const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text';
if (selectedFieldIsTextType && selectedFieldBits.length > 0) {
const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.');
const [foundKeywordField] = fields.filter(
({ name }) => keywordField !== '' && keywordField === name
);
return foundKeywordField;
}
return undefined;
};
/**
* Formats the entry into one that is easily usable for the UI, most of the
* complexity was introduced with nested fields
*
* @param patterns IIndexPattern containing available fields on rule index
* @param item exception item entry
* @param itemIndex entry index
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
*/
export const getFormattedBuilderEntry = (
indexPattern: IIndexPattern,
item: BuilderEntry,
itemIndex: number,
parent: EntryNested | undefined,
parentIndex: number | undefined
): FormattedBuilderEntry => {
const { fields } = indexPattern;
const field = parent != null ? `${parent.field}.${item.field}` : item.field;
const [foundField] = fields.filter(({ name }) => field != null && field === name);
const correspondingKeywordField = getCorrespondingKeywordField({
fields,
selectedField: field,
});
if (parent != null && parentIndex != null) {
return {
correspondingKeywordField,
entryIndex: itemIndex,
field:
foundField != null
? { ...foundField, name: foundField.name.split('.').slice(-1)[0] }
: foundField,
id: item.id ?? `${itemIndex}`,
nested: 'child',
operator: getExceptionOperatorSelect(item),
parent: { parent, parentIndex },
value: getEntryValue(item),
};
} else {
return {
correspondingKeywordField,
entryIndex: itemIndex,
field: foundField,
id: item.id ?? `${itemIndex}`,
nested: undefined,
operator: getExceptionOperatorSelect(item),
parent: undefined,
value: getEntryValue(item),
};
}
};
/**
* Formats the entries to be easily usable for the UI, most of the
* complexity was introduced with nested fields
*
* @param patterns IIndexPattern containing available fields on rule index
* @param entries exception item entries
* @param addNested boolean noting whether or not UI is currently
* set to add a nested field
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
*/
export const getFormattedBuilderEntries = (
indexPattern: IIndexPattern,
entries: BuilderEntry[],
parent?: EntryNested,
parentIndex?: number
): FormattedBuilderEntry[] => {
return entries.reduce<FormattedBuilderEntry[]>((acc, item, index) => {
const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0;
if (item.type !== 'nested' && !isNewNestedEntry) {
const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry(
indexPattern,
item,
index,
parent,
parentIndex
);
return [...acc, newItemEntry];
} else {
const parentEntry: FormattedBuilderEntry = {
correspondingKeywordField: undefined,
entryIndex: index,
field: isNewNestedEntry
? undefined
: {
aggregatable: false,
esTypes: ['nested'],
name: item.field ?? '',
searchable: false,
type: 'string',
},
id: item.id ?? `${index}`,
nested: 'parent',
operator: isOperator,
parent: undefined,
value: undefined,
};
// User has selected to add a nested field, but not yet selected the field
if (isNewNestedEntry) {
return [...acc, parentEntry];
}
if (isEntryNested(item)) {
const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index);
return [...acc, parentEntry, ...nestedItems];
}
return [...acc];
}
}, []);
};

View file

@ -39,3 +39,6 @@ export {
UseExceptionListsSuccess,
} from './exceptions/types';
export { BuilderEntryItem } from './exceptions/components/builder/entry_renderer';
export { BuilderAndBadgeComponent } from './exceptions/components/builder/and_badge';
export { BuilderEntryDeleteButtonComponent } from './exceptions/components/builder/entry_delete_button';
export { BuilderExceptionListItemComponent } from './exceptions/components/builder/exception_item_renderer';

View file

@ -115,7 +115,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({
onRuleChange,
alertStatus,
}: AddExceptionModalProps) {
const { http } = useKibana().services;
const { http, data } = useKibana().services;
const [errorsExist, setErrorExists] = useState(false);
const [comment, setComment] = useState('');
const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId);
@ -394,6 +394,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({
<EuiText>{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
<EuiSpacer />
<ExceptionBuilderComponent
httpService={http}
autocompleteService={data.autocomplete}
exceptionListItems={initialExceptionItems}
listType={exceptionListType}
listId={ruleExceptionList.list_id}

View file

@ -5,54 +5,15 @@
* 2.0.
*/
import {
fields,
getField,
} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { getEntryNestedMock } from '../../../../../../lists/common/schemas/types/entry_nested.mock';
import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock';
import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock';
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { isOneOfOperator, isOperator } from '../../autocomplete/operators';
import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types';
import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks';
import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import { EntryNested, OperatorTypeEnum, OperatorEnum } from '../../../../shared_imports';
import {
filterIndexPatterns,
getFormattedBuilderEntries,
getFormattedBuilderEntry,
getUpdatedEntriesOnDelete,
isEntryNested,
getCorrespondingKeywordField,
} from './helpers';
import { ENTRIES_WITH_IDS } from '../../../../../../lists/common/constants.mock';
import { filterIndexPatterns } from './helpers';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('123'),
}));
const getEntryNestedWithIdMock = () => ({
id: '123',
...getEntryNestedMock(),
});
const getEntryExistsWithIdMock = () => ({
id: '123',
...getEntryExistsMock(),
});
const getEntryMatchWithIdMock = () => ({
id: '123',
...getEntryMatchMock(),
});
const getEntryMatchAnyWithIdMock = () => ({
id: '123',
...getEntryMatchAnyMock(),
});
const getMockIndexPattern = (): IIndexPattern => ({
id: '1234',
title: 'logstash-*',
@ -105,421 +66,4 @@ describe('Exception builder helpers', () => {
expect(output).toEqual({ ...getMockIndexPattern(), fields: [...mockEndpointFields] });
});
});
describe('#getCorrespondingKeywordField', () => {
test('it returns matching keyword field if "selectedFieldIsTextType" is true and keyword field exists', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: 'machine.os.raw.text',
});
expect(output).toEqual(getField('machine.os.raw'));
});
test('it returns undefined if "selectedFieldIsTextType" is false', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: 'machine.os.raw',
});
expect(output).toEqual(undefined);
});
test('it returns undefined if "selectedField" is empty string', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: '',
});
expect(output).toEqual(undefined);
});
test('it returns undefined if "selectedField" is undefined', () => {
const output = getCorrespondingKeywordField({
fields,
selectedField: undefined,
});
expect(output).toEqual(undefined);
});
});
describe('#getFormattedBuilderEntry', () => {
test('it returns entry with a value for "correspondingKeywordField" when "item.field" is of type "text" and matching keyword field exists', () => {
const payloadIndexPattern: IIndexPattern = {
...getMockIndexPattern(),
fields: [
...fields,
{
name: 'machine.os.raw.text',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: true,
},
],
};
const payloadItem: BuilderEntry = {
...getEntryMatchWithIdMock(),
field: 'machine.os.raw.text',
value: 'some os',
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
undefined,
undefined
);
const expected: FormattedBuilderEntry = {
id: '123',
entryIndex: 0,
field: {
name: 'machine.os.raw.text',
type: 'string',
esTypes: ['text'],
count: 0,
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: true,
},
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some os',
correspondingKeywordField: getField('machine.os.raw'),
};
expect(output).toEqual(expected);
});
test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItem: BuilderEntry = { ...getEntryMatchWithIdMock(), field: 'child' };
const payloadParent: EntryNested = {
...getEntryNestedWithIdMock(),
field: 'nestedField',
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
payloadParent,
1
);
const expected: FormattedBuilderEntry = {
id: '123',
entryIndex: 0,
field: {
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'child',
readFromDocValues: false,
scripted: false,
searchable: true,
subType: {
nested: {
path: 'nestedField',
},
},
type: 'string',
},
nested: 'child',
operator: isOperator,
parent: {
parent: {
id: '123',
entries: [{ ...payloadItem }],
field: 'nestedField',
type: OperatorTypeEnum.NESTED,
},
parentIndex: 1,
},
value: 'some host name',
correspondingKeywordField: undefined,
};
expect(output).toEqual(expected);
});
test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItem: BuilderEntry = {
...getEntryMatchWithIdMock(),
field: 'ip',
value: 'some ip',
};
const output = getFormattedBuilderEntry(
payloadIndexPattern,
payloadItem,
0,
undefined,
undefined
);
const expected: FormattedBuilderEntry = {
id: '123',
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
correspondingKeywordField: undefined,
};
expect(output).toEqual(expected);
});
});
describe('#isEntryNested', () => {
test('it returns "false" if payload is not of type EntryNested', () => {
const payload: BuilderEntry = getEntryMatchWithIdMock();
const output = isEntryNested(payload);
const expected = false;
expect(output).toEqual(expected);
});
test('it returns "true if payload is of type EntryNested', () => {
const payload: EntryNested = getEntryNestedWithIdMock();
const output = isEntryNested(payload);
const expected = true;
expect(output).toEqual(expected);
});
});
describe('#getFormattedBuilderEntries', () => {
test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItems: BuilderEntry[] = [getEntryMatchWithIdMock()];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
id: '123',
entryIndex: 0,
field: undefined,
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some host name',
correspondingKeywordField: undefined,
},
];
expect(output).toEqual(expected);
});
test('it returns formatted entries when no nested entries exist', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadItems: BuilderEntry[] = [
{ ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' },
{ ...getEntryMatchAnyWithIdMock(), field: 'extension', value: ['some extension'] },
];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
id: '123',
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
correspondingKeywordField: undefined,
},
{
id: '123',
entryIndex: 1,
field: {
aggregatable: true,
count: 0,
esTypes: ['keyword'],
name: 'extension',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'string',
},
nested: undefined,
operator: isOneOfOperator,
parent: undefined,
value: ['some extension'],
correspondingKeywordField: undefined,
},
];
expect(output).toEqual(expected);
});
test('it returns formatted entries when nested entries exist', () => {
const payloadIndexPattern: IIndexPattern = getMockIndexPattern();
const payloadParent: EntryNested = {
...getEntryNestedWithIdMock(),
field: 'nestedField',
entries: [{ ...getEntryMatchWithIdMock(), field: 'child' }],
};
const payloadItems: BuilderEntry[] = [
{ ...getEntryMatchWithIdMock(), field: 'ip', value: 'some ip' },
{ ...payloadParent },
];
const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems);
const expected: FormattedBuilderEntry[] = [
{
id: '123',
entryIndex: 0,
field: {
aggregatable: true,
count: 0,
esTypes: ['ip'],
name: 'ip',
readFromDocValues: true,
scripted: false,
searchable: true,
type: 'ip',
},
nested: undefined,
operator: isOperator,
parent: undefined,
value: 'some ip',
correspondingKeywordField: undefined,
},
{
id: '123',
entryIndex: 1,
field: {
aggregatable: false,
esTypes: ['nested'],
name: 'nestedField',
searchable: false,
type: 'string',
},
nested: 'parent',
operator: isOperator,
parent: undefined,
value: undefined,
correspondingKeywordField: undefined,
},
{
id: '123',
entryIndex: 0,
field: {
aggregatable: false,
count: 0,
esTypes: ['text'],
name: 'child',
readFromDocValues: false,
scripted: false,
searchable: true,
subType: {
nested: {
path: 'nestedField',
},
},
type: 'string',
},
nested: 'child',
operator: isOperator,
parent: {
parent: {
id: '123',
entries: [
{
id: '123',
field: 'child',
operator: OperatorEnum.INCLUDED,
type: OperatorTypeEnum.MATCH,
value: 'some host name',
},
],
field: 'nestedField',
type: OperatorTypeEnum.NESTED,
},
parentIndex: 1,
},
value: 'some host name',
correspondingKeywordField: undefined,
},
];
expect(output).toEqual(expected);
});
});
describe('#getUpdatedEntriesOnDelete', () => {
test('it removes entry corresponding to "entryIndex"', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: ENTRIES_WITH_IDS,
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, null);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
id: '123',
field: 'some.not.nested.field',
operator: OperatorEnum.INCLUDED,
type: OperatorTypeEnum.MATCH,
value: 'some value',
},
],
};
expect(output).toEqual(expected);
});
test('it removes nested entry of "entryIndex" with corresponding parent index', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryExistsWithIdMock() }, { ...getEntryMatchAnyWithIdMock() }],
},
],
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{ ...getEntryNestedWithIdMock(), entries: [{ ...getEntryMatchAnyWithIdMock() }] },
],
};
expect(output).toEqual(expected);
});
test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => {
const payloadItem: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [
{
...getEntryNestedWithIdMock(),
entries: [{ ...getEntryExistsWithIdMock() }],
},
],
};
const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0);
const expected: ExceptionsBuilderExceptionItem = {
...getExceptionListItemSchemaMock(),
entries: [],
};
expect(output).toEqual(expected);
});
});
});

View file

@ -7,22 +7,9 @@
import uuid from 'uuid';
import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common';
import {
OperatorTypeEnum,
EntryNested,
ExceptionListType,
OperatorEnum,
} from '../../../../lists_plugin_deps';
import { isOperator } from '../../autocomplete/operators';
import {
FormattedBuilderEntry,
ExceptionsBuilderExceptionItem,
EmptyEntry,
EmptyNestedEntry,
BuilderEntry,
} from '../types';
import { getEntryValue, getExceptionOperatorSelect } from '../helpers';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import { OperatorTypeEnum, ExceptionListType, OperatorEnum } from '../../../../lists_plugin_deps';
import { ExceptionsBuilderExceptionItem, EmptyEntry, EmptyNestedEntry } from '../types';
import exceptionableFields from '../exceptionable_fields.json';
export const filterIndexPatterns = (
@ -37,222 +24,6 @@ export const filterIndexPatterns = (
: patterns;
};
/**
* Fields of type 'text' do not generate autocomplete values, we want
* to find it's corresponding keyword type (if available) which does
* generate autocomplete values
*
* @param fields IFieldType fields
* @param selectedField the field name that was selected
* @param isTextType we only want a corresponding keyword field if
* the selected field is of type 'text'
*
*/
export const getCorrespondingKeywordField = ({
fields,
selectedField,
}: {
fields: IFieldType[];
selectedField: string | undefined;
}): IFieldType | undefined => {
const selectedFieldBits =
selectedField != null && selectedField !== '' ? selectedField.split('.') : [];
const selectedFieldIsTextType = selectedFieldBits.slice(-1)[0] === 'text';
if (selectedFieldIsTextType && selectedFieldBits.length > 0) {
const keywordField = selectedFieldBits.slice(0, selectedFieldBits.length - 1).join('.');
const [foundKeywordField] = fields.filter(
({ name }) => keywordField !== '' && keywordField === name
);
return foundKeywordField;
}
return undefined;
};
/**
* Formats the entry into one that is easily usable for the UI, most of the
* complexity was introduced with nested fields
*
* @param patterns IIndexPattern containing available fields on rule index
* @param item exception item entry
* @param itemIndex entry index
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
*/
export const getFormattedBuilderEntry = (
indexPattern: IIndexPattern,
item: BuilderEntry,
itemIndex: number,
parent: EntryNested | undefined,
parentIndex: number | undefined
): FormattedBuilderEntry => {
const { fields } = indexPattern;
const field = parent != null ? `${parent.field}.${item.field}` : item.field;
const [foundField] = fields.filter(({ name }) => field != null && field === name);
const correspondingKeywordField = getCorrespondingKeywordField({
fields,
selectedField: field,
});
if (parent != null && parentIndex != null) {
return {
field:
foundField != null
? { ...foundField, name: foundField.name.split('.').slice(-1)[0] }
: foundField,
correspondingKeywordField,
id: item.id ?? `${itemIndex}`,
operator: getExceptionOperatorSelect(item),
value: getEntryValue(item),
nested: 'child',
parent: { parent, parentIndex },
entryIndex: itemIndex,
};
} else {
return {
field: foundField,
id: item.id ?? `${itemIndex}`,
correspondingKeywordField,
operator: getExceptionOperatorSelect(item),
value: getEntryValue(item),
nested: undefined,
parent: undefined,
entryIndex: itemIndex,
};
}
};
export const isEntryNested = (item: BuilderEntry): item is EntryNested => {
return (item as EntryNested).entries != null;
};
/**
* Formats the entries to be easily usable for the UI, most of the
* complexity was introduced with nested fields
*
* @param patterns IIndexPattern containing available fields on rule index
* @param entries exception item entries
* @param addNested boolean noting whether or not UI is currently
* set to add a nested field
* @param parent nested entries hold copy of their parent for use in various logic
* @param parentIndex corresponds to the entry index, this might seem obvious, but
* was added to ensure that nested items could be identified with their parent entry
*/
export const getFormattedBuilderEntries = (
indexPattern: IIndexPattern,
entries: BuilderEntry[],
parent?: EntryNested,
parentIndex?: number
): FormattedBuilderEntry[] => {
return entries.reduce<FormattedBuilderEntry[]>((acc, item, index) => {
const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0;
if (item.type !== 'nested' && !isNewNestedEntry) {
const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry(
indexPattern,
item,
index,
parent,
parentIndex
);
return [...acc, newItemEntry];
} else {
const parentEntry: FormattedBuilderEntry = {
operator: isOperator,
id: item.id ?? `${index}`,
nested: 'parent',
field: isNewNestedEntry
? undefined
: {
name: item.field ?? '',
aggregatable: false,
searchable: false,
type: 'string',
esTypes: ['nested'],
},
value: undefined,
entryIndex: index,
parent: undefined,
correspondingKeywordField: undefined,
};
// User has selected to add a nested field, but not yet selected the field
if (isNewNestedEntry) {
return [...acc, parentEntry];
}
if (isEntryNested(item)) {
const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index);
return [...acc, parentEntry, ...nestedItems];
}
return [...acc];
}
}, []);
};
/**
* Determines whether an entire entry, exception item, or entry within a nested
* entry needs to be removed
*
* @param exceptionItem
* @param entryIndex index of given entry, for nested entries, this will correspond
* to their parent index
* @param nestedEntryIndex index of nested entry
*
*/
export const getUpdatedEntriesOnDelete = (
exceptionItem: ExceptionsBuilderExceptionItem,
entryIndex: number,
nestedParentIndex: number | null
): ExceptionsBuilderExceptionItem => {
const itemOfInterest: BuilderEntry = exceptionItem.entries[nestedParentIndex ?? entryIndex];
if (nestedParentIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) {
const updatedEntryEntries = [
...itemOfInterest.entries.slice(0, entryIndex),
...itemOfInterest.entries.slice(entryIndex + 1),
];
if (updatedEntryEntries.length === 0) {
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, nestedParentIndex),
...exceptionItem.entries.slice(nestedParentIndex + 1),
],
};
} else {
const { field } = itemOfInterest;
const updatedItemOfInterest: EntryNested | EmptyNestedEntry = {
field,
id: itemOfInterest.id ?? `${entryIndex}`,
type: OperatorTypeEnum.NESTED,
entries: updatedEntryEntries,
};
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, nestedParentIndex),
updatedItemOfInterest,
...exceptionItem.entries.slice(nestedParentIndex + 1),
],
};
}
} else {
return {
...exceptionItem,
entries: [
...exceptionItem.entries.slice(0, entryIndex),
...exceptionItem.entries.slice(entryIndex + 1),
],
};
}
};
export const getDefaultEmptyEntry = (): EmptyEntry => ({
id: uuid.v4(),
field: '',

View file

@ -17,37 +17,26 @@ import {
import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock';
import { useKibana } from '../../../../common/lib/kibana';
import { getEmptyValue } from '../../empty_value';
import { ExceptionBuilderComponent } from './';
import { getMockTheme } from '../../../lib/kibana/kibana_react.mock';
import { coreMock } from 'src/core/public/mocks';
import { dataPluginMock } from 'src/plugins/data/public/mocks';
const mockTheme = getMockTheme({
eui: {
euiColorLightShade: '#ece',
},
});
jest.mock('../../../../common/lib/kibana');
const mockKibanaHttpService = coreMock.createStart().http;
const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract();
describe('ExceptionBuilderComponent', () => {
let wrapper: ReactWrapper;
const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']);
beforeEach(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
data: {
autocomplete: {
getValueSuggestions: getValueSuggestionsMock,
},
},
},
});
});
afterEach(() => {
getValueSuggestionsMock.mockClear();
jest.clearAllMocks();
@ -58,6 +47,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -94,6 +85,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[
{
...getExceptionListItemSchemaMock(),
@ -136,6 +129,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -169,6 +164,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -223,6 +220,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -281,6 +280,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[
{
...getExceptionListItemSchemaMock(),
@ -333,6 +334,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -366,6 +369,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"
@ -402,6 +407,8 @@ describe('ExceptionBuilderComponent', () => {
wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionBuilderComponent
httpService={mockKibanaHttpService}
autocompleteService={autocompleteStartMock}
exceptionListItems={[]}
listType="detection"
listId="list_id"

View file

@ -9,11 +9,14 @@ import React, { useCallback, useEffect, useReducer } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { HttpStart } from 'kibana/public';
import { AutocompleteStart } from 'src/plugins/data/public';
import { isEqlRule, isThresholdRule } from '../../../../../common/detection_engine/utils';
import { addIdToItem } from '../../../../../common';
import { Type } from '../../../../../common/detection_engine/schemas/common/schemas';
import { BuilderExceptionListItemComponent } from './exception_item';
import { IIndexPattern } from '../../../../../../../../src/plugins/data/common';
import {
BuilderExceptionListItemComponent,
ExceptionListItemSchema,
NamespaceType,
exceptionListItemSchema,
@ -22,7 +25,7 @@ import {
CreateExceptionListItemSchema,
ExceptionListType,
entriesNested,
} from '../../../../../public/lists_plugin_deps';
} from '../../../../../public/shared_imports';
import { AndOrBadge } from '../../and_or_badge';
import { BuilderLogicButtons } from './logic_buttons';
import { getNewExceptionItem, filterExceptionItems } from '../helpers';
@ -66,6 +69,8 @@ interface OnChangeProps {
}
interface ExceptionBuilderProps {
httpService: HttpStart;
autocompleteService: AutocompleteStart;
exceptionListItems: ExceptionsBuilderExceptionItem[];
listType: ExceptionListType;
listId: string;
@ -80,6 +85,8 @@ interface ExceptionBuilderProps {
}
export const ExceptionBuilderComponent = ({
httpService,
autocompleteService,
exceptionListItems,
listType,
listId,
@ -374,6 +381,9 @@ export const ExceptionBuilderComponent = ({
))}
<EuiFlexItem grow={false}>
<BuilderExceptionListItemComponent
allowLargeValueLists={!isEqlRule(ruleType) && !isThresholdRule(ruleType)}
httpService={httpService}
autocompleteService={autocompleteService}
key={getExceptionListItemId(exceptionListItem, index)}
exceptionItem={exceptionListItem}
indexPattern={indexPatterns}
@ -385,7 +395,6 @@ export const ExceptionBuilderComponent = ({
onChangeExceptionItem={handleExceptionItemChange}
onlyShowListOperators={containsValueListEntry(exceptions)}
setErrorsExist={setErrorsExist}
ruleType={ruleType}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -98,7 +98,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({
onConfirm,
onRuleChange,
}: EditExceptionModalProps) {
const { http } = useKibana().services;
const { http, data } = useKibana().services;
const [comment, setComment] = useState('');
const [errorsExist, setErrorExists] = useState(false);
const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId);
@ -313,6 +313,8 @@ export const EditExceptionModal = memo(function EditExceptionModal({
<EuiText>{i18n.EXCEPTION_BUILDER_INFO}</EuiText>
<EuiSpacer />
<ExceptionBuilderComponent
httpService={http}
autocompleteService={data.autocomplete}
exceptionListItems={[exceptionItem]}
listType={exceptionListType}
listId={exceptionItem.list_id}

View file

@ -1512,13 +1512,13 @@ Object {
data-test-subj="trustedAppsListPage"
>
<header
class="sc-pZNLs imoSYB siemHeaderPage"
class="sc-pjGtN iqOyKV siemHeaderPage"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem sc-oTOtL cKcAUE"
class="euiFlexItem sc-psbuI gHZSXZ"
>
<h1
class="euiTitle euiTitle--large"
@ -1538,7 +1538,7 @@ Object {
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero sc-oTOtL cKcAUE"
class="euiFlexItem euiFlexItem--flexGrowZero sc-psbuI gHZSXZ"
data-test-subj="header-page-supplements"
>
<button
@ -1599,7 +1599,7 @@ Object {
class="euiSpacer euiSpacer--l"
/>
<div
class="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--shadow sc-oUqyN jIrDlk"
class="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--shadow sc-plgA-D cjCgbf"
>
<div
data-eui="EuiFocusTrap"
@ -1784,7 +1784,7 @@ Object {
>
<div>
<div
class="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--directionRow euiFlexGroup--responsive sc-pcLzI gaXJWX"
class="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--directionRow euiFlexGroup--responsive sc-ptBBy hxgCsO"
data-test-subj="addTrustedAppFlyout-createForm-conditionsBuilder-group1"
>
<div
@ -2060,7 +2060,7 @@ Object {
class="euiFormRow__fieldWrapper"
>
<div
class="sc-plgA-D bsISVJ"
class="sc-qPzgd jNOPOu"
>
<div
class="euiFormRow euiFormRow--fullWidth"
@ -2390,18 +2390,18 @@ Object {
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrow2 sc-qPzgd aiUzK"
class="euiFlexItem euiFlexItem--flexGrow2 sc-pAkMi kGVKuo"
>
<dl
class="euiDescriptionList euiDescriptionList--column euiDescriptionList--compressed"
>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Name
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -2412,12 +2412,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
OS
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -2428,12 +2428,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Date Created
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor eui-textTruncate"
@ -2442,12 +2442,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Created By
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -2458,12 +2458,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Description
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -2476,7 +2476,7 @@ Object {
</dl>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrow5 sc-qXUgY fyGsco"
class="euiFlexItem euiFlexItem--flexGrow5 sc-pIgJL dxkHYu"
>
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionColumn euiFlexGroup--responsive"
@ -4295,13 +4295,13 @@ Object {
data-test-subj="trustedAppsListPage"
>
<header
class="sc-pZNLs imoSYB siemHeaderPage"
class="sc-pjGtN iqOyKV siemHeaderPage"
>
<div
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem sc-oTOtL cKcAUE"
class="euiFlexItem sc-psbuI gHZSXZ"
>
<h1
class="euiTitle euiTitle--large"
@ -4321,7 +4321,7 @@ Object {
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero sc-oTOtL cKcAUE"
class="euiFlexItem euiFlexItem--flexGrowZero sc-psbuI gHZSXZ"
data-test-subj="header-page-supplements"
>
<button
@ -4382,7 +4382,7 @@ Object {
class="euiSpacer euiSpacer--l"
/>
<div
class="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--shadow sc-oUqyN jIrDlk"
class="euiPanel euiPanel--paddingMedium euiPanel--borderRadiusMedium euiPanel--plain euiPanel--shadow sc-plgA-D cjCgbf"
>
<div
data-eui="EuiFocusTrap"
@ -4567,7 +4567,7 @@ Object {
>
<div>
<div
class="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--directionRow euiFlexGroup--responsive sc-pcLzI gaXJWX"
class="euiFlexGroup euiFlexGroup--gutterExtraSmall euiFlexGroup--directionRow euiFlexGroup--responsive sc-ptBBy hxgCsO"
data-test-subj="addTrustedAppFlyout-createForm-conditionsBuilder-group1"
>
<div
@ -5016,18 +5016,18 @@ Object {
class="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--directionRow euiFlexGroup--responsive"
>
<div
class="euiFlexItem euiFlexItem--flexGrow2 sc-qPzgd aiUzK"
class="euiFlexItem euiFlexItem--flexGrow2 sc-pAkMi kGVKuo"
>
<dl
class="euiDescriptionList euiDescriptionList--column euiDescriptionList--compressed"
>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Name
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -5038,12 +5038,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
OS
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -5054,12 +5054,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Date Created
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor eui-textTruncate"
@ -5068,12 +5068,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Created By
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -5084,12 +5084,12 @@ Object {
</span>
</dd>
<dt
class="euiDescriptionList__title sc-pAkMi hqItBe"
class="euiDescriptionList__title sc-pQfSI jJIOeJ"
>
Description
</dt>
<dd
class="euiDescriptionList__description sc-pIgJL cnELLG eui-textBreakWord"
class="euiDescriptionList__description sc-pYbQl KYizM eui-textBreakWord"
>
<span
class="euiToolTipAnchor"
@ -5102,7 +5102,7 @@ Object {
</dl>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrow5 sc-qXUgY fyGsco"
class="euiFlexItem euiFlexItem--flexGrow5 sc-pIgJL dxkHYu"
>
<div
class="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--directionColumn euiFlexGroup--responsive"

View file

@ -59,4 +59,7 @@ export {
addEndpointExceptionList,
withOptionalSignal,
BuilderEntryItem,
BuilderAndBadgeComponent,
BuilderEntryDeleteButtonComponent,
BuilderExceptionListItemComponent,
} from '../../lists/public';