Skip to content

Commit

Permalink
[SIEM][Exceptions] - Exception builder component (#67013) (#70539)
Browse files Browse the repository at this point in the history
### 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
  • Loading branch information
yctercero authored Jul 2, 2020
1 parent 9fd8a86 commit b2bd4fe
Show file tree
Hide file tree
Showing 48 changed files with 3,623 additions and 980 deletions.
33 changes: 32 additions & 1 deletion x-pack/plugins/lists/common/schemas/common/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
4 changes: 4 additions & 0 deletions x-pack/plugins/lists/common/schemas/common/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '';
Expand All @@ -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"
Expand All @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
},
]);
});
});
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit b2bd4fe

Please sign in to comment.