[Security Solution] - Update exceptions item UI (#135255)

## Summary

Addresses https://github.com/elastic/kibana/issues/135254
This commit is contained in:
Yara Tercero 2022-07-08 13:34:37 -07:00 committed by GitHub
parent 6b018f797b
commit 0a0b0a18e2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1051 additions and 1823 deletions

View file

@ -14,7 +14,7 @@ import { addExceptionFromFirstAlert, goToClosedAlerts, goToOpenedAlerts } from '
import { createCustomRuleEnabled } from '../../tasks/api_calls/rules';
import { goToRuleDetails } from '../../tasks/alerts_detection_rules';
import { waitForAlertsToPopulate } from '../../tasks/create_new_rule';
import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver';
import { esArchiverLoad, esArchiverUnload, esArchiverResetKibana } from '../../tasks/es_archiver';
import { login, visitWithoutDateRange } from '../../tasks/login';
import {
addsException,
@ -26,15 +26,15 @@ import {
} from '../../tasks/rule_details';
import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation';
import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common';
import { deleteAlertsAndRules } from '../../tasks/common';
describe('Adds rule exception', () => {
const NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS = '1 alert';
before(() => {
cleanKibana();
login();
esArchiverResetKibana();
esArchiverLoad('exceptions');
login();
});
beforeEach(() => {

View file

@ -13,7 +13,11 @@ import { createCustomRule } from '../../tasks/api_calls/rules';
import { goToRuleDetails } from '../../tasks/alerts_detection_rules';
import { esArchiverLoad, esArchiverResetKibana, esArchiverUnload } from '../../tasks/es_archiver';
import { login, visitWithoutDateRange } from '../../tasks/login';
import { openExceptionFlyoutFromRuleSettings, goToExceptionsTab } from '../../tasks/rule_details';
import {
openExceptionFlyoutFromRuleSettings,
goToExceptionsTab,
editException,
} from '../../tasks/rule_details';
import {
addExceptionEntryFieldMatchAnyValue,
addExceptionEntryFieldValue,
@ -32,7 +36,6 @@ import {
EXCEPTION_ITEM_CONTAINER,
ADD_EXCEPTIONS_BTN,
EXCEPTION_FIELD_LIST,
EDIT_EXCEPTIONS_BTN,
EXCEPTION_EDIT_FLYOUT_SAVE_BTN,
EXCEPTION_FLYOUT_VERSION_CONFLICT,
EXCEPTION_FLYOUT_LIST_DELETED_ERROR,
@ -302,8 +305,7 @@ describe('Exceptions flyout', () => {
context('When updating an item with version conflict', () => {
it('Displays version conflict error', () => {
cy.get(EDIT_EXCEPTIONS_BTN).should('be.visible');
cy.get(EDIT_EXCEPTIONS_BTN).click({ force: true });
editException();
// update exception item via api
updateExceptionListItem('simple_list_item', {
@ -334,8 +336,7 @@ describe('Exceptions flyout', () => {
context('When updating an item for a list that has since been deleted', () => {
it('Displays missing exception list error', () => {
cy.get(EDIT_EXCEPTIONS_BTN).should('be.visible');
cy.get(EDIT_EXCEPTIONS_BTN).click({ force: true });
editException();
// delete exception list via api
deleteExceptionList(getExceptionList().list_id, getExceptionList().namespace_type);

View file

@ -5,8 +5,6 @@
* 2.0.
*/
export const EDIT_EXCEPTIONS_BTN = '[data-test-subj="exceptionsViewerEditBtn"]';
export const ADD_EXCEPTIONS_BTN = '[data-test-subj="exceptionsHeaderAddExceptionBtn"]';
export const CLOSE_ALERTS_CHECKBOX =

View file

@ -78,7 +78,12 @@ export const RISK_SCORE_OVERRIDE_DETAILS = 'Risk score override';
export const REFERENCE_URLS_DETAILS = 'Reference URLs';
export const REMOVE_EXCEPTION_BTN = '[data-test-subj="exceptionsViewerDeleteBtn"]';
export const EXCEPTION_ITEM_ACTIONS_BUTTON =
'button[data-test-subj="exceptionItemCardHeader-actionButton"]';
export const REMOVE_EXCEPTION_BTN = '[data-test-subj="exceptionItemCardHeader-actionItem-delete"]';
export const EDIT_EXCEPTION_BTN = '[data-test-subj="exceptionItemCardHeader-actionItem-edit"]';
export const RULE_SWITCH = '[data-test-subj="ruleSwitch"]';

View file

@ -29,6 +29,8 @@ import {
INDEX_PATTERNS_DETAILS,
DETAILS_TITLE,
DETAILS_DESCRIPTION,
EXCEPTION_ITEM_ACTIONS_BUTTON,
EDIT_EXCEPTION_BTN,
} from '../screens/rule_details';
import { addsFields, closeFieldsBrowser, filterFieldsBrowser } from './fields_browser';
@ -96,7 +98,15 @@ export const goToExceptionsTab = () => {
.should('be.visible');
};
export const editException = () => {
cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).click();
cy.get(EDIT_EXCEPTION_BTN).click();
};
export const removeException = () => {
cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).click();
cy.get(REMOVE_EXCEPTION_BTN).click();
};

View file

@ -1,252 +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
* 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 moment from 'moment-timezone';
import { ExceptionDetails } from './exception_details';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock';
import { getMockTheme } from '../../../../lib/kibana/kibana_react.mock';
const mockTheme = getMockTheme({
eui: {
euiColorLightestShade: '#ece',
},
});
describe('ExceptionDetails', () => {
beforeEach(() => {
moment.tz.setDefault('UTC');
});
afterEach(() => {
moment.tz.setDefault('Browser');
});
test('it renders no comments button if no comments exist', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = [];
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]')).toHaveLength(0);
});
test('it renders comments button if comments exist', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(
wrapper.find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]')
).toHaveLength(1);
});
test('it renders correct number of comments', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = [getCommentsArrayMock()[0]];
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual(
'Show (1) Comment'
);
});
test('it renders comments plural if more than one', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual(
'Show (2) Comments'
);
});
test('it renders comments show text if "showComments" is false', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual(
'Show (2) Comments'
);
});
test('it renders comments hide text if "showComments" is true', () => {
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual(
'Hide (2) Comments'
);
});
test('it invokes "onCommentsClick" when comments button clicked', () => {
const mockOnCommentsClick = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={mockOnCommentsClick}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
const commentsBtn = wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0);
commentsBtn.simulate('click');
expect(mockOnCommentsClick).toHaveBeenCalledTimes(1);
});
test('it renders the operating system if one is specified in the exception item', () => {
const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] });
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiDescriptionListTitle').at(0).text()).toEqual('OS');
expect(wrapper.find('EuiDescriptionListDescription').at(0).text()).toEqual('Linux');
});
test('it renders the exception item creator', () => {
const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] });
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiDescriptionListTitle').at(1).text()).toEqual('Date created');
expect(wrapper.find('EuiDescriptionListDescription').at(1).text()).toEqual(
'April 20th 2020 @ 15:25:31'
);
});
test('it renders the exception item creation timestamp', () => {
const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] });
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiDescriptionListTitle').at(2).text()).toEqual('Created by');
expect(wrapper.find('EuiDescriptionListDescription').at(2).text()).toEqual('some user');
});
test('it renders the description if one is included on the exception item', () => {
const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] });
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={true}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Description');
expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual('some description');
});
test('it renders with Name and Modified info when showName and showModified props are true', () => {
const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] });
exceptionItem.comments = [];
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionDetails
showComments={false}
onCommentsClick={jest.fn()}
exceptionItem={exceptionItem}
showName={true}
showModified={true}
/>
</ThemeProvider>
);
expect(wrapper.find('EuiDescriptionListTitle').at(0).text()).toEqual('Name');
expect(wrapper.find('EuiDescriptionListDescription').at(0).text()).toEqual('some name');
expect(wrapper.find('EuiDescriptionListTitle').at(4).text()).toEqual('Date modified');
expect(wrapper.find('EuiDescriptionListDescription').at(4).text()).toEqual(
'April 20th 2020 @ 15:25:31'
);
expect(wrapper.find('EuiDescriptionListTitle').at(5).text()).toEqual('Modified by');
expect(wrapper.find('EuiDescriptionListDescription').at(5).text()).toEqual('some user');
});
});

View file

@ -1,121 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiFlexItem,
EuiFlexGroup,
EuiDescriptionList,
EuiButtonEmpty,
EuiDescriptionListTitle,
EuiToolTip,
} from '@elastic/eui';
import React, { useMemo, Fragment } from 'react';
import styled, { css } from 'styled-components';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import type { DescriptionListItem } from '../../types';
import { getDescriptionListContent } from '../helpers';
import * as i18n from '../../translations';
const MyExceptionDetails = styled(EuiFlexItem)`
${({ theme }) => css`
background-color: ${theme.eui.euiColorLightestShade};
padding: ${theme.eui.euiSize};
.eventFiltersDescriptionList {
margin: ${theme.eui.euiSize} ${theme.eui.euiSize} 0 ${theme.eui.euiSize};
}
.eventFiltersDescriptionListTitle {
width: 40%;
margin-top: 0;
margin-bottom: ${theme.eui.euiSizeS};
}
.eventFiltersDescriptionListDescription {
width: 60%;
margin-top: 0;
margin-bottom: ${theme.eui.euiSizeS};
}
`}
`;
const StyledCommentsSection = styled(EuiFlexItem)`
${({ theme }) => css`
&&& {
margin: ${theme.eui.euiSizeXS} ${theme.eui.euiSize} ${theme.eui.euiSizeL} ${theme.eui.euiSize};
}
`}
`;
const ExceptionDetailsComponent = ({
showComments,
showModified = false,
showName = false,
onCommentsClick,
exceptionItem,
}: {
showComments: boolean;
showModified?: boolean;
showName?: boolean;
exceptionItem: ExceptionListItemSchema;
onCommentsClick: () => void;
}): JSX.Element => {
const descriptionListItems = useMemo(
(): DescriptionListItem[] => getDescriptionListContent(exceptionItem, showModified, showName),
[exceptionItem, showModified, showName]
);
const commentsSection = useMemo((): JSX.Element => {
const { comments } = exceptionItem;
if (comments.length > 0) {
return (
<EuiButtonEmpty
onClick={onCommentsClick}
flush="left"
size="xs"
data-test-subj="exceptionsViewerItemCommentsBtn"
>
{!showComments
? i18n.COMMENTS_SHOW(comments.length)
: i18n.COMMENTS_HIDE(comments.length)}
</EuiButtonEmpty>
);
} else {
return <></>;
}
}, [showComments, onCommentsClick, exceptionItem]);
return (
<MyExceptionDetails grow={2}>
<EuiFlexGroup direction="column" alignItems="flexStart">
<EuiFlexItem grow={1} className="eventFiltersDescriptionList">
<EuiDescriptionList
compressed
type="responsiveColumn"
data-test-subj="exceptionsViewerItemDetails"
>
{descriptionListItems.map((item) => (
<Fragment key={`${item.title}`}>
<EuiToolTip content={item.title} anchorClassName="eventFiltersDescriptionListTitle">
<EuiDescriptionListTitle className="eui-textTruncate eui-fullWidth">
{item.title}
</EuiDescriptionListTitle>
</EuiToolTip>
{item.description}
</Fragment>
))}
</EuiDescriptionList>
</EuiFlexItem>
<StyledCommentsSection grow={false}>{commentsSection}</StyledCommentsSection>
</EuiFlexGroup>
</MyExceptionDetails>
);
};
ExceptionDetailsComponent.displayName = 'ExceptionDetailsComponent';
export const ExceptionDetails = React.memo(ExceptionDetailsComponent);
ExceptionDetails.displayName = 'ExceptionDetails';

View file

@ -1,193 +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
* 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 { ExceptionEntries } from './exception_entries';
import { getFormattedEntryMock } from '../../exceptions.mock';
import { getEmptyValue } from '../../../empty_value';
import { getMockTheme } from '../../../../lib/kibana/kibana_react.mock';
const mockTheme = getMockTheme({
eui: { euiSize: '10px', euiColorPrimary: '#ece', euiColorDanger: '#ece' },
});
describe('ExceptionEntries', () => {
test('it does NOT render the and badge if only one exception item entry exists', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerAndBadge"]')).toHaveLength(0);
});
test('it renders the and badge if more than one exception item exists', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[getFormattedEntryMock(), getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionsViewerAndBadge"]')).toHaveLength(1);
});
test('it invokes "onEdit" when edit button clicked', () => {
const mockOnEdit = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={mockOnEdit}
/>
</ThemeProvider>
);
const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0);
editBtn.simulate('click');
expect(mockOnEdit).toHaveBeenCalledTimes(1);
});
test('it invokes "onDelete" when delete button clicked', () => {
const mockOnDelete = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[getFormattedEntryMock()]}
onDelete={mockOnDelete}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
const deleteBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0);
deleteBtn.simulate('click');
expect(mockOnDelete).toHaveBeenCalledTimes(1);
});
test('it does not render edit button if "disableActions" is "true"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={true}
entries={[getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
const editBtns = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button');
expect(editBtns).toHaveLength(0);
});
test('it does not render delete button if "disableActions" is "true"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={true}
entries={[getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
const deleteBtns = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0);
expect(deleteBtns).toHaveLength(0);
});
test('it renders nested entry', () => {
const parentEntry = getFormattedEntryMock();
parentEntry.operator = undefined;
parentEntry.value = undefined;
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[parentEntry, getFormattedEntryMock(true)]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
const parentField = wrapper
.find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent')
.at(0);
const parentOperator = wrapper
.find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent')
.at(0);
const parentValue = wrapper
.find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent')
.at(0);
const nestedField = wrapper
.find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent')
.at(1);
const nestedOperator = wrapper
.find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent')
.at(1);
const nestedValue = wrapper
.find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent')
.at(1);
expect(parentField.text()).toEqual('host.name');
expect(parentOperator.text()).toEqual(getEmptyValue());
expect(parentValue.text()).toEqual(getEmptyValue());
expect(nestedField.exists('.euiToolTipAnchor')).toBeTruthy();
expect(nestedField.text()).toContain('host.name');
expect(nestedOperator.text()).toEqual('is');
expect(nestedValue.text()).toEqual('some name');
});
test('it renders non-nested entries', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionEntries
disableActions={false}
entries={[getFormattedEntryMock()]}
onDelete={jest.fn()}
onEdit={jest.fn()}
/>
</ThemeProvider>
);
const field = wrapper
.find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent')
.at(0);
const operator = wrapper
.find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent')
.at(0);
const value = wrapper
.find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent')
.at(0);
expect(field.exists('.euiToolTipAnchor')).toBeFalsy();
expect(field.text()).toEqual('host.name');
expect(operator.text()).toEqual('is');
expect(value.text()).toEqual('some name');
});
});

View file

@ -1,218 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiBasicTable,
EuiIconTip,
EuiFlexItem,
EuiFlexGroup,
EuiButton,
EuiTableFieldDataColumnType,
EuiHideFor,
EuiBadge,
EuiBadgeGroup,
EuiToolTip,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import styled, { css } from 'styled-components';
import { transparentize } from 'polished';
import { AndOrBadge } from '../../../and_or_badge';
import { getEmptyValue } from '../../../empty_value';
import * as i18n from '../../translations';
import { FormattedEntry } from '../../types';
const MyEntriesDetails = styled(EuiFlexItem)`
${({ theme }) => css`
padding: ${theme.eui.euiSize} ${theme.eui.euiSizeL} ${theme.eui.euiSizeL} ${theme.eui.euiSizeXS};
&&& {
margin-left: 0;
}
`}
`;
const MyEditButton = styled(EuiButton)`
${({ theme }) => css`
background-color: ${transparentize(0.9, theme.eui.euiColorPrimary)};
border: none;
font-weight: ${theme.eui.euiFontWeightSemiBold};
`}
`;
const MyRemoveButton = styled(EuiButton)`
${({ theme }) => css`
background-color: ${transparentize(0.9, theme.eui.euiColorDanger)};
border: none;
font-weight: ${theme.eui.euiFontWeightSemiBold};
`}
`;
const MyAndOrBadgeContainer = styled(EuiFlexItem)`
${({ theme }) => css`
padding: ${theme.eui.euiSizeXL} ${theme.eui.euiSize} ${theme.eui.euiSizeS} 0;
`}
`;
const MyActionButton = styled(EuiFlexItem)`
align-self: flex-end;
`;
const MyNestedValueContainer = styled.div`
margin-left: ${({ theme }) => theme.eui.euiSizeL};
`;
const MyNestedValue = styled.span`
margin-left: ${({ theme }) => theme.eui.euiSizeS};
`;
const ValueBadgeGroup = styled(EuiBadgeGroup)`
width: 100%;
`;
interface ExceptionEntriesComponentProps {
entries: FormattedEntry[];
disableActions: boolean;
onDelete: () => void;
onEdit: () => void;
}
const ExceptionEntriesComponent = ({
entries,
disableActions,
onDelete,
onEdit,
}: ExceptionEntriesComponentProps): JSX.Element => {
const columns = useMemo(
(): Array<EuiTableFieldDataColumnType<FormattedEntry>> => [
{
field: 'fieldName',
name: 'Field',
sortable: false,
truncateText: true,
textOnly: true,
'data-test-subj': 'exceptionFieldNameCell',
width: '30%',
render: (value: string | null, data: FormattedEntry) => {
if (value != null && data.isNested) {
return (
<MyNestedValueContainer>
<EuiIconTip type="nested" size="s" />
<MyNestedValue>{value}</MyNestedValue>
</MyNestedValueContainer>
);
} else {
return value ?? getEmptyValue();
}
},
},
{
field: 'operator',
name: 'Operator',
sortable: false,
truncateText: true,
'data-test-subj': 'exceptionFieldOperatorCell',
width: '20%',
render: (value: string | null) => value ?? getEmptyValue(),
},
{
field: 'value',
name: 'Value',
sortable: false,
truncateText: true,
'data-test-subj': 'exceptionFieldValueCell',
width: '60%',
render: (values: string | string[] | null) => {
if (Array.isArray(values)) {
return (
<ValueBadgeGroup gutterSize="xs">
{values.map((value) => {
return (
<EuiBadge color="#DDD" key={value}>
{value}
</EuiBadge>
);
})}
</ValueBadgeGroup>
);
} else {
return values ? (
<EuiToolTip content={values} anchorClassName="eui-textTruncate">
<span>{values}</span>
</EuiToolTip>
) : (
getEmptyValue()
);
}
},
},
],
[]
);
return (
<MyEntriesDetails grow={5}>
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" gutterSize="none">
{entries.length > 1 && (
<EuiHideFor sizes={['xs', 's']}>
<MyAndOrBadgeContainer grow={false}>
<AndOrBadge
type="and"
includeAntennas
data-test-subj="exceptionsViewerAndBadge"
/>
</MyAndOrBadgeContainer>
</EuiHideFor>
)}
<EuiFlexItem grow={1}>
<EuiBasicTable
isSelectable={false}
itemId="id"
columns={columns}
items={entries}
responsive
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
{!disableActions && (
<EuiFlexItem grow={1}>
<EuiFlexGroup gutterSize="s" justifyContent="flexEnd">
<MyActionButton grow={false}>
<MyEditButton
size="s"
color="primary"
onClick={onEdit}
data-test-subj="exceptionsViewerEditBtn"
>
{i18n.EDIT}
</MyEditButton>
</MyActionButton>
<MyActionButton grow={false}>
<MyRemoveButton
size="s"
color="danger"
onClick={onDelete}
data-test-subj="exceptionsViewerDeleteBtn"
>
{i18n.REMOVE}
</MyRemoveButton>
</MyActionButton>
</EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup>
</MyEntriesDetails>
);
};
ExceptionEntriesComponent.displayName = 'ExceptionEntriesComponent';
export const ExceptionEntries = React.memo(ExceptionEntriesComponent);
ExceptionEntries.displayName = 'ExceptionEntries';

View file

@ -1,162 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { storiesOf, addDecorator } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import React from 'react';
import { ThemeProvider } from 'styled-components';
import { euiLightVars } from '@kbn/ui-theme';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock';
import { ExceptionItem } from '.';
addDecorator((storyFn) => (
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>{storyFn()}</ThemeProvider>
));
storiesOf('Components/ExceptionItem', module)
.add('with os', () => {
const payload = getExceptionListItemSchemaMock();
payload.description = '';
payload.comments = [];
payload.entries = [
{
field: 'actingProcess.file.signer',
type: 'match',
operator: 'included',
value: 'Elastic, N.V.',
},
];
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with description', () => {
const payload = getExceptionListItemSchemaMock();
payload.comments = [];
payload.entries = [
{
field: 'actingProcess.file.signer',
type: 'match',
operator: 'included',
value: 'Elastic, N.V.',
},
];
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with comments', () => {
const payload = getExceptionListItemSchemaMock();
payload.description = '';
payload.comments = getCommentsArrayMock();
payload.entries = [
{
field: 'actingProcess.file.signer',
type: 'match',
operator: 'included',
value: 'Elastic, N.V.',
},
];
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with nested entries', () => {
const payload = getExceptionListItemSchemaMock();
payload.description = '';
payload.comments = [];
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with everything', () => {
const payload = getExceptionListItemSchemaMock();
payload.comments = getCommentsArrayMock();
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with loadingItemIds', () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { id, namespace_type, ...rest } = getExceptionListItemSchemaMock();
return (
<ExceptionItem
disableActions={false}
loadingItemIds={[{ id, namespaceType: namespace_type }]}
commentsAccordionId={'accordion--comments'}
exceptionItem={{ id, namespace_type, ...rest }}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
})
.add('with actions disabled', () => {
const payload = getExceptionListItemSchemaMock();
payload.description = '';
payload.comments = getCommentsArrayMock();
payload.entries = [
{
field: 'actingProcess.file.signer',
type: 'match',
operator: 'included',
value: 'Elastic, N.V.',
},
];
return (
<ExceptionItem
disableActions
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
exceptionItem={payload}
onDeleteException={action('onClick')}
onEditException={action('onClick')}
/>
);
});

View file

@ -1,127 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiPanel,
EuiFlexGroup,
EuiCommentProps,
EuiCommentList,
EuiAccordion,
EuiFlexItem,
} from '@elastic/eui';
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import styled from 'styled-components';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionDetails } from './exception_details';
import { ExceptionEntries } from './exception_entries';
import { getFormattedComments } from '../../helpers';
import { getFormattedEntries } from '../helpers';
import type { FormattedEntry, ExceptionListItemIdentifiers } from '../../types';
const MyFlexItem = styled(EuiFlexItem)`
&.comments--show {
padding: ${({ theme }) => theme.eui.euiSize};
border-top: ${({ theme }) => `${theme.eui.euiBorderThin}`};
}
`;
export interface ExceptionItemProps {
loadingItemIds: ExceptionListItemIdentifiers[];
exceptionItem: ExceptionListItemSchema;
commentsAccordionId: string;
onDeleteException: (arg: ExceptionListItemIdentifiers) => void;
onEditException: (item: ExceptionListItemSchema) => void;
showName?: boolean;
showModified?: boolean;
disableActions: boolean;
'data-test-subj'?: string;
}
const ExceptionItemComponent = ({
disableActions,
loadingItemIds,
exceptionItem,
commentsAccordionId,
onDeleteException,
onEditException,
showModified = false,
showName = false,
'data-test-subj': dataTestSubj,
}: ExceptionItemProps): JSX.Element => {
const [entryItems, setEntryItems] = useState<FormattedEntry[]>([]);
const [showComments, setShowComments] = useState(false);
useEffect((): void => {
const formattedEntries = getFormattedEntries(exceptionItem.entries);
setEntryItems(formattedEntries);
}, [exceptionItem.entries]);
const handleDelete = useCallback((): void => {
onDeleteException({
id: exceptionItem.id,
namespaceType: exceptionItem.namespace_type,
});
}, [onDeleteException, exceptionItem.id, exceptionItem.namespace_type]);
const handleEdit = useCallback((): void => {
onEditException(exceptionItem);
}, [onEditException, exceptionItem]);
const onCommentsClick = useCallback((): void => {
setShowComments(!showComments);
}, [setShowComments, showComments]);
const formattedComments = useMemo((): EuiCommentProps[] => {
return getFormattedComments(exceptionItem.comments);
}, [exceptionItem.comments]);
const disableItemActions = useMemo((): boolean => {
const foundItems = loadingItemIds.filter(({ id }) => id === exceptionItem.id);
return foundItems.length > 0;
}, [loadingItemIds, exceptionItem.id]);
return (
<EuiPanel paddingSize="none" data-test-subj={dataTestSubj} hasBorder hasShadow={false}>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup direction="row">
<ExceptionDetails
showComments={showComments}
exceptionItem={exceptionItem}
onCommentsClick={onCommentsClick}
showModified={showModified}
showName={showName}
/>
<ExceptionEntries
disableActions={disableItemActions || disableActions}
entries={entryItems}
onDelete={handleDelete}
onEdit={handleEdit}
/>
</EuiFlexGroup>
</EuiFlexItem>
<MyFlexItem className={showComments ? 'comments--show' : ''}>
<EuiAccordion
id={commentsAccordionId}
arrowDisplay="none"
forceState={showComments ? 'open' : 'closed'}
data-test-subj="exceptionsViewerCommentAccordion"
>
<EuiCommentList comments={formattedComments} />
</EuiAccordion>
</MyFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
ExceptionItemComponent.displayName = 'ExceptionItemComponent';
export const ExceptionItem = React.memo(ExceptionItemComponent);
ExceptionItem.displayName = 'ExceptionItem';

View file

@ -0,0 +1,111 @@
/*
* 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 { TestProviders } from '../../../../mock';
import { ExceptionItemCardConditions } from './exception_item_card_conditions';
describe('ExceptionItemCardConditions', () => {
it('it includes os condition if one exists', () => {
const wrapper = mount(
<TestProviders>
<ExceptionItemCardConditions
os={['linux']}
entries={[
{
field: 'host.name',
operator: 'included',
type: 'match',
value: 'host',
},
{
field: 'threat.indicator.port',
operator: 'included',
type: 'exists',
},
{
entries: [
{
field: 'valid',
operator: 'included',
type: 'match',
value: 'true',
},
],
field: 'file.Ext.code_signature',
type: 'nested',
},
]}
dataTestSubj="exceptionItemConditions"
/>
</TestProviders>
);
// Text is gonna look a bit off unformatted
expect(wrapper.find('[data-test-subj="exceptionItemConditions-os"]').at(0).text()).toEqual(
' OSIS Linux'
);
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(0).text()
).toEqual(' host.nameIS host');
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(1).text()
).toEqual('AND threat.indicator.portexists ');
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(2).text()
).toEqual('AND file.Ext.code_signature validIS true');
});
it('it renders item conditions', () => {
const wrapper = mount(
<TestProviders>
<ExceptionItemCardConditions
entries={[
{
field: 'host.name',
operator: 'included',
type: 'match',
value: 'host',
},
{
field: 'threat.indicator.port',
operator: 'included',
type: 'exists',
},
{
entries: [
{
field: 'valid',
operator: 'included',
type: 'match',
value: 'true',
},
],
field: 'file.Ext.code_signature',
type: 'nested',
},
]}
dataTestSubj="exceptionItemConditions"
/>
</TestProviders>
);
// Text is gonna look a bit off unformatted
expect(wrapper.find('[data-test-subj="exceptionItemConditions-os"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(0).text()
).toEqual(' host.nameIS host');
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(1).text()
).toEqual('AND threat.indicator.portexists ');
expect(
wrapper.find('[data-test-subj="exceptionItemConditions-condition"]').at(2).text()
).toEqual('AND file.Ext.code_signature validIS true');
});
});

View file

@ -0,0 +1,160 @@
/*
* 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, { memo, useMemo, useCallback } from 'react';
import { EuiExpression, EuiToken, EuiFlexGroup, EuiFlexItem, EuiBadge } from '@elastic/eui';
import styled from 'styled-components';
import {
ExceptionListItemSchema,
ListOperatorTypeEnum,
NonEmptyNestedEntriesArray,
} from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from './translations';
const OS_LABELS = Object.freeze({
linux: i18n.OS_LINUX,
mac: i18n.OS_MAC,
macos: i18n.OS_MAC,
windows: i18n.OS_WINDOWS,
});
const OPERATOR_TYPE_LABELS_INCLUDED = Object.freeze({
[ListOperatorTypeEnum.NESTED]: i18n.CONDITION_OPERATOR_TYPE_NESTED,
[ListOperatorTypeEnum.MATCH_ANY]: i18n.CONDITION_OPERATOR_TYPE_MATCH_ANY,
[ListOperatorTypeEnum.MATCH]: i18n.CONDITION_OPERATOR_TYPE_MATCH,
[ListOperatorTypeEnum.WILDCARD]: i18n.CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES,
[ListOperatorTypeEnum.EXISTS]: i18n.CONDITION_OPERATOR_TYPE_EXISTS,
[ListOperatorTypeEnum.LIST]: i18n.CONDITION_OPERATOR_TYPE_LIST,
});
const OPERATOR_TYPE_LABELS_EXCLUDED = Object.freeze({
[ListOperatorTypeEnum.MATCH_ANY]: i18n.CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY,
[ListOperatorTypeEnum.MATCH]: i18n.CONDITION_OPERATOR_TYPE_NOT_MATCH,
});
const EuiFlexGroupNested = styled(EuiFlexGroup)`
margin-left: ${({ theme }) => theme.eui.euiSizeXL};
`;
const EuiFlexItemNested = styled(EuiFlexItem)`
margin-bottom: 6px !important;
margin-top: 6px !important;
`;
const StyledCondition = styled('span')`
margin-right: 6px;
`;
export interface CriteriaConditionsProps {
entries: ExceptionListItemSchema['entries'];
dataTestSubj: string;
os?: ExceptionListItemSchema['os_types'];
}
export const ExceptionItemCardConditions = memo<CriteriaConditionsProps>(
({ os, entries, dataTestSubj }) => {
const osLabel = useMemo(() => {
if (os != null && os.length > 0) {
return os
.map((osValue) => OS_LABELS[osValue as keyof typeof OS_LABELS] ?? osValue)
.join(', ');
}
return null;
}, [os]);
const getEntryValue = (type: string, value: string | string[] | undefined) => {
if (type === 'match_any' && Array.isArray(value)) {
return value.map((currentValue) => <EuiBadge color="hollow">{currentValue}</EuiBadge>);
}
return value ?? '';
};
const getEntryOperator = (type: string, operator: string) => {
if (type === 'nested') return '';
return operator === 'included'
? OPERATOR_TYPE_LABELS_INCLUDED[type as keyof typeof OPERATOR_TYPE_LABELS_INCLUDED] ?? type
: OPERATOR_TYPE_LABELS_EXCLUDED[type as keyof typeof OPERATOR_TYPE_LABELS_EXCLUDED] ?? type;
};
const getNestedEntriesContent = useCallback(
(type: string, nestedEntries: NonEmptyNestedEntriesArray) => {
if (type === 'nested' && nestedEntries.length) {
return nestedEntries.map((entry) => {
const { field: nestedField, type: nestedType, operator: nestedOperator } = entry;
const nestedValue = 'value' in entry ? entry.value : '';
return (
<EuiFlexGroupNested
data-test-subj={`${dataTestSubj}-nestedCondition`}
key={nestedField + nestedType + nestedValue}
direction="row"
alignItems="center"
gutterSize="m"
responsive={false}
>
<EuiFlexItemNested grow={false}>
<EuiToken iconType="tokenNested" size="s" />
</EuiFlexItemNested>
<EuiFlexItemNested grow={false}>
<EuiExpression description={''} value={nestedField} color="subdued" />
</EuiFlexItemNested>
<EuiFlexItemNested grow={false}>
<EuiExpression
description={getEntryOperator(nestedType, nestedOperator)}
value={getEntryValue(nestedType, nestedValue)}
/>
</EuiFlexItemNested>
</EuiFlexGroupNested>
);
});
}
},
[dataTestSubj]
);
return (
<div data-test-subj={dataTestSubj}>
{osLabel != null && (
<div data-test-subj={`${dataTestSubj}-os`}>
<strong>
<EuiExpression description={''} value={i18n.CONDITION_OS} />
<EuiExpression description={i18n.CONDITION_OPERATOR_TYPE_MATCH} value={osLabel} />
</strong>
</div>
)}
{entries.map((entry, index) => {
const { field, type } = entry;
const value = 'value' in entry ? entry.value : '';
const nestedEntries = 'entries' in entry ? entry.entries : [];
const operator = 'operator' in entry ? entry.operator : '';
return (
<div data-test-subj={`${dataTestSubj}-condition`} key={field + type + value + index}>
<div className="eui-xScroll">
<EuiExpression
description={
index === 0 ? '' : <StyledCondition>{i18n.CONDITION_AND}</StyledCondition>
}
value={field}
color={index === 0 ? 'primary' : 'subdued'}
/>
<EuiExpression
description={getEntryOperator(type, operator)}
value={getEntryValue(type, value)}
/>
</div>
{nestedEntries != null && getNestedEntriesContent(type, nestedEntries)}
</div>
);
})}
</div>
);
}
);
ExceptionItemCardConditions.displayName = 'ExceptionItemCardConditions';

View file

@ -0,0 +1,128 @@
/*
* 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 { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { ThemeProvider } from 'styled-components';
import * as i18n from './translations';
import { ExceptionItemCardHeader } from './exception_item_card_header';
import { getMockTheme } from '../../../../lib/kibana/kibana_react.mock';
const mockTheme = getMockTheme({
eui: {
euiSize: '10px',
euiColorPrimary: '#ece',
euiColorDanger: '#ece',
},
});
describe('ExceptionItemCardHeader', () => {
it('it renders item name', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItemCardHeader
item={getExceptionListItemSchemaMock()}
dataTestSubj="exceptionItemHeader"
actions={[
{
key: 'edit',
icon: 'pencil',
label: i18n.EXCEPTION_ITEM_EDIT_BUTTON,
onClick: jest.fn(),
},
{
key: 'delete',
icon: 'trash',
label: i18n.EXCEPTION_ITEM_DELETE_BUTTON,
onClick: jest.fn(),
},
]}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="exceptionItemHeader-title"]').at(0).text()).toEqual(
'some name'
);
});
it('it displays actions', () => {
const handleEdit = jest.fn();
const handleDelete = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItemCardHeader
actions={[
{
key: 'edit',
icon: 'pencil',
label: i18n.EXCEPTION_ITEM_EDIT_BUTTON,
onClick: handleEdit,
},
{
key: 'delete',
icon: 'trash',
label: i18n.EXCEPTION_ITEM_DELETE_BUTTON,
onClick: handleDelete,
},
]}
item={getExceptionListItemSchemaMock()}
dataTestSubj="exceptionItemHeader"
/>
</ThemeProvider>
);
// click on popover
wrapper
.find('button[data-test-subj="exceptionItemHeader-actionButton"]')
.at(0)
.simulate('click');
wrapper.find('button[data-test-subj="exceptionItemHeader-actionItem-edit"]').simulate('click');
expect(handleEdit).toHaveBeenCalled();
wrapper
.find('button[data-test-subj="exceptionItemHeader-actionItem-delete"]')
.simulate('click');
expect(handleDelete).toHaveBeenCalled();
});
it('it disables actions if disableActions is true', () => {
const handleEdit = jest.fn();
const handleDelete = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItemCardHeader
actions={[
{
key: 'edit',
icon: 'pencil',
label: i18n.EXCEPTION_ITEM_EDIT_BUTTON,
onClick: handleEdit,
},
{
key: 'delete',
icon: 'trash',
label: i18n.EXCEPTION_ITEM_DELETE_BUTTON,
onClick: handleDelete,
},
]}
item={getExceptionListItemSchemaMock()}
disableActions
dataTestSubj="exceptionItemHeader"
/>
</ThemeProvider>
);
expect(
wrapper.find('button[data-test-subj="exceptionItemHeader-actionButton"]').at(0).props()
.disabled
).toBeTruthy();
});
});

View file

@ -0,0 +1,82 @@
/*
* 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, { memo, useMemo, useState } from 'react';
import {
EuiButtonIcon,
EuiContextMenuPanelProps,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiTitle,
EuiContextMenuItem,
} from '@elastic/eui';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
export interface ExceptionItemCardHeaderProps {
item: ExceptionListItemSchema;
actions: Array<{ key: string; icon: string; label: string; onClick: () => void }>;
disableActions?: boolean;
dataTestSubj: string;
}
export const ExceptionItemCardHeader = memo<ExceptionItemCardHeaderProps>(
({ item, actions, disableActions = false, dataTestSubj }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onItemActionsClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const onClosePopover = () => setIsPopoverOpen(false);
const itemActions = useMemo((): EuiContextMenuPanelProps['items'] => {
return actions.map((action) => (
<EuiContextMenuItem
data-test-subj={`${dataTestSubj}-actionItem-${action.key}`}
key={action.key}
icon={action.icon}
onClick={() => {
onClosePopover();
action.onClick();
}}
>
{action.label}
</EuiContextMenuItem>
));
}, [dataTestSubj, actions]);
return (
<EuiFlexGroup data-test-subj={dataTestSubj} justifyContent="spaceBetween">
<EuiFlexItem grow={9}>
<EuiTitle size="xs" textTransform="uppercase" data-test-subj={`${dataTestSubj}-title`}>
<h3>{item.name}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
button={
<EuiButtonIcon
isDisabled={disableActions}
aria-label="Exception item actions menu"
iconType="boxesHorizontal"
onClick={onItemActionsClick}
data-test-subj={`${dataTestSubj}-actionButton`}
/>
}
panelPaddingSize="none"
isOpen={isPopoverOpen}
closePopover={onClosePopover}
data-test-subj={`${dataTestSubj}-items`}
>
<EuiContextMenuPanel size="s" items={itemActions} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ExceptionItemCardHeader.displayName = 'ExceptionItemCardHeader';

View file

@ -0,0 +1,51 @@
/*
* 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 { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { TestProviders } from '../../../../mock';
import { ExceptionItemCardMetaInfo } from './exception_item_card_meta';
describe('ExceptionItemCardMetaInfo', () => {
it('it renders item creation info', () => {
const wrapper = mount(
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value1"]').at(0).text()
).toEqual('Apr 20, 2020 @ 15:25:31.830');
expect(
wrapper.find('[data-test-subj="exceptionItemMeta-createdBy-value2"]').at(0).text()
).toEqual('some user');
});
it('it renders item update info', () => {
const wrapper = mount(
<TestProviders>
<ExceptionItemCardMetaInfo
item={getExceptionListItemSchemaMock()}
dataTestSubj="exceptionItemMeta"
/>
</TestProviders>
);
expect(
wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value1"]').at(0).text()
).toEqual('Apr 20, 2020 @ 15:25:31.830');
expect(
wrapper.find('[data-test-subj="exceptionItemMeta-updatedBy-value2"]').at(0).text()
).toEqual('some user');
});
});

View file

@ -0,0 +1,111 @@
/*
* 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, { memo } from 'react';
import { EuiAvatar, EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import styled from 'styled-components';
import * as i18n from './translations';
import { FormattedDate, FormattedRelativePreferenceDate } from '../../../formatted_date';
const StyledCondition = styled('div')`
padding-top: 4px !important;
`;
export interface ExceptionItemCardMetaInfoProps {
item: ExceptionListItemSchema;
dataTestSubj: string;
}
export const ExceptionItemCardMetaInfo = memo<ExceptionItemCardMetaInfoProps>(
({ item, dataTestSubj }) => {
return (
<EuiFlexGroup
alignItems="center"
responsive={false}
gutterSize="s"
data-test-subj={dataTestSubj}
>
<EuiFlexItem grow={false}>
<MetaInfoDetails
fieldName="created_by"
label={i18n.EXCEPTION_ITEM_CREATED_LABEL}
value1={<FormattedDate fieldName="created_by" value={item.created_at} />}
value2={item.created_by}
dataTestSubj={`${dataTestSubj}-createdBy`}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MetaInfoDetails
fieldName="updated_by"
label={i18n.EXCEPTION_ITEM_UPDATED_LABEL}
value1={
<StyledCondition>
<FormattedRelativePreferenceDate
value={item.updated_at}
tooltipFieldName="updated_by"
tooltipAnchorClassName="eui-textTruncate"
/>
</StyledCondition>
}
value2={item.updated_by}
dataTestSubj={`${dataTestSubj}-updatedBy`}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
ExceptionItemCardMetaInfo.displayName = 'ExceptionItemCardMetaInfo';
interface MetaInfoDetailsProps {
fieldName: string;
label: string;
value1: JSX.Element | string;
value2: string;
dataTestSubj: string;
}
const MetaInfoDetails = memo<MetaInfoDetailsProps>(({ label, value1, value2, dataTestSubj }) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="s" wrap={false} responsive={false}>
<EuiFlexItem grow={false}>
<EuiBadge color="default" style={{ fontFamily: 'Inter' }}>
{label}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem grow={false} data-test-subj={`${dataTestSubj}-value1`}>
<EuiText size="xs" style={{ fontFamily: 'Inter' }}>
{value1}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs" style={{ fontStyle: 'italic', fontFamily: 'Inter' }}>
{i18n.EXCEPTION_ITEM_META_BY}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup responsive={false} gutterSize="xs" alignItems="center" wrap={false}>
<EuiFlexItem grow={false}>
<EuiAvatar initialsLength={2} name={value2.toUpperCase()} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiText
size="xs"
style={{ fontFamily: 'Inter' }}
data-test-subj={`${dataTestSubj}-value2`}
>
{value2}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
});
MetaInfoDetails.displayName = 'MetaInfoDetails';

View file

@ -9,7 +9,7 @@ import React from 'react';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { ExceptionItem } from '.';
import { ExceptionItemCard } from '.';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock';
import { getMockTheme } from '../../../../lib/kibana/kibana_react.mock';
@ -25,75 +25,72 @@ const mockTheme = getMockTheme({
},
});
describe('ExceptionItem', () => {
it('it renders ExceptionDetails and ExceptionEntries', () => {
const exceptionItem = getExceptionListItemSchemaMock();
describe('ExceptionItemCard', () => {
it('it renders header, item meta information and conditions', () => {
const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: [] };
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={jest.fn()}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
dataTestSubj="item"
/>
</ThemeProvider>
);
expect(wrapper.find('ExceptionDetails')).toHaveLength(1);
expect(wrapper.find('ExceptionEntries')).toHaveLength(1);
expect(wrapper.find('ExceptionItemCardHeader')).toHaveLength(1);
expect(wrapper.find('ExceptionItemCardMetaInfo')).toHaveLength(1);
expect(wrapper.find('ExceptionItemCardConditions')).toHaveLength(1);
expect(
wrapper.find('[data-test-subj="exceptionsViewerCommentAccordion"]').exists()
).toBeFalsy();
});
it('it renders ExceptionDetails with Name and Modified info when showName and showModified are true ', () => {
const exceptionItem = getExceptionListItemSchemaMock();
it('it renders header, item meta information, conditions, and comments if any exist', () => {
const exceptionItem = { ...getExceptionListItemSchemaMock(), comments: getCommentsArrayMock() };
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={jest.fn()}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
showModified={true}
showName={true}
dataTestSubj="item"
/>
</ThemeProvider>
);
expect(wrapper.find('ExceptionDetails').props()).toEqual(
expect.objectContaining({
showModified: true,
showName: true,
})
);
expect(wrapper.find('ExceptionItemCardHeader')).toHaveLength(1);
expect(wrapper.find('ExceptionItemCardMetaInfo')).toHaveLength(1);
expect(wrapper.find('ExceptionItemCardConditions')).toHaveLength(1);
expect(
wrapper.find('[data-test-subj="exceptionsViewerCommentAccordion"]').exists()
).toBeTruthy();
});
it('it does not render edit or delete action buttons when "disableActions" is "true"', () => {
const mockOnEditException = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={jest.fn()}
onEditException={mockOnEditException}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
dataTestSubj="item"
/>
</ThemeProvider>
);
const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button');
const deleteBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button');
expect(editBtn).toHaveLength(0);
expect(deleteBtn).toHaveLength(0);
expect(wrapper.find('button[data-test-subj="item-actionButton"]').exists()).toBeFalsy();
});
it('it invokes "onEditException" when edit button clicked', () => {
@ -102,19 +99,25 @@ describe('ExceptionItem', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={jest.fn()}
onEditException={mockOnEditException}
exceptionItem={exceptionItem}
dataTestSubj="item"
/>
</ThemeProvider>
);
const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0);
editBtn.simulate('click');
// click on popover
wrapper
.find('button[data-test-subj="exceptionItemCardHeader-actionButton"]')
.at(0)
.simulate('click');
wrapper
.find('button[data-test-subj="exceptionItemCardHeader-actionItem-edit"]')
.simulate('click');
expect(mockOnEditException).toHaveBeenCalledWith(getExceptionListItemSchemaMock());
});
@ -125,19 +128,25 @@ describe('ExceptionItem', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={mockOnDeleteException}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
dataTestSubj="item"
/>
</ThemeProvider>
);
const deleteBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0);
deleteBtn.simulate('click');
// click on popover
wrapper
.find('button[data-test-subj="exceptionItemCardHeader-actionButton"]')
.at(0)
.simulate('click');
wrapper
.find('button[data-test-subj="exceptionItemCardHeader-actionItem-delete"]')
.simulate('click');
expect(mockOnDeleteException).toHaveBeenCalledWith({
id: '1',
@ -146,47 +155,21 @@ describe('ExceptionItem', () => {
});
it('it renders comment accordion closed to begin with', () => {
const mockOnDeleteException = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
<ExceptionItemCard
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={mockOnDeleteException}
onDeleteException={jest.fn()}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
dataTestSubj="item"
/>
</ThemeProvider>
);
expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(0);
});
it('it renders comment accordion open when showComments is true', () => {
const mockOnDeleteException = jest.fn();
const exceptionItem = getExceptionListItemSchemaMock();
exceptionItem.comments = getCommentsArrayMock();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionItem
disableActions={false}
loadingItemIds={[]}
commentsAccordionId={'accordion--comments'}
onDeleteException={mockOnDeleteException}
onEditException={jest.fn()}
exceptionItem={exceptionItem}
/>
</ThemeProvider>
);
const commentsBtn = wrapper
.find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]')
.at(0);
commentsBtn.simulate('click');
expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(1);
});
});

View file

@ -0,0 +1,131 @@
/*
* 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 {
EuiPanel,
EuiFlexGroup,
EuiCommentProps,
EuiCommentList,
EuiAccordion,
EuiFlexItem,
EuiText,
useEuiTheme,
} from '@elastic/eui';
import React, { useMemo, useCallback } from 'react';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { getFormattedComments } from '../../helpers';
import type { ExceptionListItemIdentifiers } from '../../types';
import * as i18n from './translations';
import { ExceptionItemCardHeader } from './exception_item_card_header';
import { ExceptionItemCardConditions } from './exception_item_card_conditions';
import { ExceptionItemCardMetaInfo } from './exception_item_card_meta';
export interface ExceptionItemProps {
loadingItemIds: ExceptionListItemIdentifiers[];
exceptionItem: ExceptionListItemSchema;
onDeleteException: (arg: ExceptionListItemIdentifiers) => void;
onEditException: (item: ExceptionListItemSchema) => void;
disableActions: boolean;
dataTestSubj: string;
}
const ExceptionItemCardComponent = ({
disableActions,
loadingItemIds,
exceptionItem,
onDeleteException,
onEditException,
dataTestSubj,
}: ExceptionItemProps): JSX.Element => {
const { euiTheme } = useEuiTheme();
const handleDelete = useCallback((): void => {
onDeleteException({
id: exceptionItem.id,
namespaceType: exceptionItem.namespace_type,
});
}, [onDeleteException, exceptionItem.id, exceptionItem.namespace_type]);
const handleEdit = useCallback((): void => {
onEditException(exceptionItem);
}, [onEditException, exceptionItem]);
const formattedComments = useMemo((): EuiCommentProps[] => {
return getFormattedComments(exceptionItem.comments);
}, [exceptionItem.comments]);
const disableItemActions = useMemo((): boolean => {
const foundItems = loadingItemIds.some(({ id }) => id === exceptionItem.id);
return disableActions || foundItems;
}, [loadingItemIds, exceptionItem.id, disableActions]);
return (
<EuiPanel paddingSize="l" data-test-subj={dataTestSubj} hasBorder hasShadow={false}>
<EuiFlexGroup gutterSize="m" direction="column">
<EuiFlexItem data-test-subj={`${dataTestSubj}-header`}>
<ExceptionItemCardHeader
item={exceptionItem}
actions={[
{
key: 'edit',
icon: 'pencil',
label: i18n.EXCEPTION_ITEM_EDIT_BUTTON,
onClick: handleEdit,
},
{
key: 'delete',
icon: 'trash',
label: i18n.EXCEPTION_ITEM_DELETE_BUTTON,
onClick: handleDelete,
},
]}
disableActions={disableItemActions}
dataTestSubj="exceptionItemCardHeader"
/>
</EuiFlexItem>
<EuiFlexItem data-test-subj={`${dataTestSubj}-meta`}>
<ExceptionItemCardMetaInfo
item={exceptionItem}
dataTestSubj="exceptionItemCardMetaInfo"
/>
</EuiFlexItem>
<EuiFlexItem>
<ExceptionItemCardConditions
os={exceptionItem.os_types}
entries={exceptionItem.entries}
dataTestSubj="exceptionItemCardConditions"
/>
</EuiFlexItem>
{formattedComments.length > 0 && (
<EuiFlexItem>
<EuiAccordion
id="exceptionItemCardComments"
buttonContent={
<EuiText size="s" style={{ color: euiTheme.colors.primary }}>
{i18n.exceptionItemCommentsAccordion(formattedComments.length)}
</EuiText>
}
arrowDisplay="none"
data-test-subj="exceptionsViewerCommentAccordion"
>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="m">
<EuiCommentList comments={formattedComments} />
</EuiPanel>
</EuiAccordion>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiPanel>
);
};
ExceptionItemCardComponent.displayName = 'ExceptionItemCardComponent';
export const ExceptionItemCard = React.memo(ExceptionItemCardComponent);
ExceptionItemCard.displayName = 'ExceptionItemCard';

View file

@ -0,0 +1,140 @@
/*
* 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 EXCEPTION_ITEM_EDIT_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.editItemButton',
{
defaultMessage: 'Edit item',
}
);
export const EXCEPTION_ITEM_DELETE_BUTTON = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.deleteItemButton',
{
defaultMessage: 'Delete item',
}
);
export const EXCEPTION_ITEM_CREATED_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.createdLabel',
{
defaultMessage: 'Created',
}
);
export const EXCEPTION_ITEM_UPDATED_LABEL = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.updatedLabel',
{
defaultMessage: 'Updated',
}
);
export const EXCEPTION_ITEM_META_BY = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.metaDetailsBy',
{
defaultMessage: 'by',
}
);
export const exceptionItemCommentsAccordion = (comments: number) =>
i18n.translate('xpack.securitySolution.exceptions.exceptionItem.showCommentsLabel', {
values: { comments },
defaultMessage: 'Show {comments, plural, =1 {comment} other {comments}} ({comments})',
});
export const CONDITION_OPERATOR_TYPE_MATCH = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator',
{
defaultMessage: 'IS',
}
);
export const CONDITION_OPERATOR_TYPE_NOT_MATCH = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.matchOperator.not',
{
defaultMessage: 'IS NOT',
}
);
export const CONDITION_OPERATOR_TYPE_WILDCARD_MATCHES = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.wildcardMatchesOperator',
{
defaultMessage: 'MATCHES',
}
);
export const CONDITION_OPERATOR_TYPE_NESTED = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.nestedOperator',
{
defaultMessage: 'has',
}
);
export const CONDITION_OPERATOR_TYPE_MATCH_ANY = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator',
{
defaultMessage: 'is one of',
}
);
export const CONDITION_OPERATOR_TYPE_NOT_MATCH_ANY = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.matchAnyOperator.not',
{
defaultMessage: 'is not one of',
}
);
export const CONDITION_OPERATOR_TYPE_EXISTS = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.existsOperator',
{
defaultMessage: 'exists',
}
);
export const CONDITION_OPERATOR_TYPE_LIST = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.listOperator',
{
defaultMessage: 'included in',
}
);
export const CONDITION_AND = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.and',
{
defaultMessage: 'AND',
}
);
export const CONDITION_OS = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.os',
{
defaultMessage: 'OS',
}
);
export const OS_WINDOWS = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.windows',
{
defaultMessage: 'Windows',
}
);
export const OS_LINUX = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.linux',
{
defaultMessage: 'Linux',
}
);
export const OS_MAC = i18n.translate(
'xpack.securitySolution.exceptions.exceptionItem.conditions.macos',
{
defaultMessage: 'Mac',
}
);

View file

@ -13,6 +13,7 @@ import * as i18n from '../translations';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { ExceptionsViewerItems } from './exceptions_viewer_items';
import { getMockTheme } from '../../../lib/kibana/kibana_react.mock';
import { TestProviders } from '../../../mock';
const mockTheme = getMockTheme({
eui: {
@ -25,17 +26,18 @@ const mockTheme = getMockTheme({
describe('ExceptionsViewerItems', () => {
it('it renders empty prompt if "showEmpty" is "true"', () => {
const wrapper = mount(
<ExceptionsViewerItems
showEmpty
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
<TestProviders>
<ExceptionsViewerItems
showEmpty
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy();
@ -50,19 +52,20 @@ describe('ExceptionsViewerItems', () => {
it('it renders no search results found prompt if "showNoResults" is "true"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults
isInitLoading={false}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults
isInitLoading={false}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy();
@ -75,19 +78,20 @@ describe('ExceptionsViewerItems', () => {
it('it renders exceptions if "showEmpty" and "isInitLoading" is "false", and exceptions exist', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[getExceptionListItemSchemaMock()]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[getExceptionListItemSchemaMock()]}
loadingItemIds={[]}
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeTruthy();
@ -96,103 +100,23 @@ describe('ExceptionsViewerItems', () => {
it('it does not render exceptions if "isInitLoading" is "true"', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={true}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
<TestProviders>
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={true}
disableActions={false}
exceptions={[]}
loadingItemIds={[]}
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="exceptionsContainer"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="exceptionsEmptyPrompt"]').exists()).toBeTruthy();
});
it('it does not render or badge for first exception displayed', () => {
const exception1 = getExceptionListItemSchemaMock();
const exception2 = getExceptionListItemSchemaMock();
exception2.id = 'newId';
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[exception1, exception2]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
);
const firstExceptionItem = wrapper.find('[data-test-subj="exceptionItemContainer"]').at(0);
expect(firstExceptionItem.find('[data-test-subj="exceptionItemOrBadge"]').exists()).toBeFalsy();
});
it('it does render or badge with exception displayed', () => {
const exception1 = getExceptionListItemSchemaMock();
const exception2 = getExceptionListItemSchemaMock();
exception2.id = 'newId';
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[exception1, exception2]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={jest.fn()}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
);
const notFirstExceptionItem = wrapper.find('[data-test-subj="exceptionItemContainer"]').at(1);
expect(
notFirstExceptionItem.find('[data-test-subj="exceptionItemOrBadge"]').exists()
).toBeFalsy();
});
it('it invokes "onDeleteException" when delete button is clicked', () => {
const mockOnDeleteException = jest.fn();
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<ExceptionsViewerItems
showEmpty={false}
showNoResults={false}
isInitLoading={false}
disableActions={false}
exceptions={[getExceptionListItemSchemaMock()]}
loadingItemIds={[]}
commentsAccordionId="comments-accordion-id"
onDeleteException={mockOnDeleteException}
onEditExceptionItem={jest.fn()}
/>
</ThemeProvider>
);
wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0).simulate('click');
expect(mockOnDeleteException).toHaveBeenCalledWith({
id: '1',
namespaceType: 'single',
});
});
});

View file

@ -6,13 +6,12 @@
*/
import React from 'react';
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import * as i18n from '../translations';
import { ExceptionItem } from './exception_item';
import { AndOrBadge } from '../../and_or_badge';
import { ExceptionItemCard } from './exception_item_card';
import type { ExceptionListItemIdentifiers } from '../types';
const MyFlexItem = styled(EuiFlexItem)`
@ -34,7 +33,6 @@ interface ExceptionsViewerItemsProps {
disableActions: boolean;
exceptions: ExceptionListItemSchema[];
loadingItemIds: ExceptionListItemIdentifiers[];
commentsAccordionId: string;
onDeleteException: (arg: ExceptionListItemIdentifiers) => void;
onEditExceptionItem: (item: ExceptionListItemSchema) => void;
}
@ -45,7 +43,6 @@ const ExceptionsViewerItemsComponent: React.FC<ExceptionsViewerItemsProps> = ({
isInitLoading,
exceptions,
loadingItemIds,
commentsAccordionId,
onDeleteException,
onEditExceptionItem,
disableActions,
@ -79,23 +76,15 @@ const ExceptionsViewerItemsComponent: React.FC<ExceptionsViewerItemsProps> = ({
>
{!isInitLoading &&
exceptions.length > 0 &&
exceptions.map((exception, index) => (
exceptions.map((exception) => (
<MyFlexItem data-test-subj="exceptionItemContainer" grow={false} key={exception.id}>
{index !== 0 ? (
<>
<AndOrBadge data-test-subj="exceptionItemOrBadge" type="or" />
<EuiSpacer />
</>
) : (
<EuiSpacer size="s" />
)}
<ExceptionItem
<ExceptionItemCard
disableActions={disableActions}
loadingItemIds={loadingItemIds}
commentsAccordionId={commentsAccordionId}
exceptionItem={exception}
onDeleteException={onDeleteException}
onEditException={onEditExceptionItem}
dataTestSubj={`exceptionItemCard-${exception.name}`}
/>
</MyFlexItem>
))}

View file

@ -1,354 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment-timezone';
import { getFormattedEntries, formatEntry, getDescriptionListContent } from './helpers';
import { FormattedEntry } from '../types';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import { getEntriesArrayMock } from '@kbn/lists-plugin/common/schemas/types/entries.mock';
import { getEntryMatchMock } from '@kbn/lists-plugin/common/schemas/types/entry_match.mock';
import { getEntryMatchAnyMock } from '@kbn/lists-plugin/common/schemas/types/entry_match_any.mock';
import { getEntryExistsMock } from '@kbn/lists-plugin/common/schemas/types/entry_exists.mock';
describe('Exception viewer helpers', () => {
beforeEach(() => {
moment.tz.setDefault('UTC');
});
afterEach(() => {
moment.tz.setDefault('Browser');
});
describe('#getFormattedEntries', () => {
test('it returns empty array if no entries passed', () => {
const result = getFormattedEntries([]);
expect(result).toEqual([]);
});
test('it formats nested entries as expected', () => {
const payload = [getEntryMatchMock()];
const result = getFormattedEntries(payload);
const expected: FormattedEntry[] = [
{
fieldName: 'host.name',
isNested: false,
operator: 'is',
value: 'some host name',
},
];
expect(result).toEqual(expected);
});
test('it formats "exists" entries as expected', () => {
const payload = [getEntryExistsMock()];
const result = getFormattedEntries(payload);
const expected: FormattedEntry[] = [
{
fieldName: 'host.name',
isNested: false,
operator: 'exists',
value: undefined,
},
];
expect(result).toEqual(expected);
});
test('it formats non-nested entries as expected', () => {
const payload = [getEntryMatchAnyMock(), getEntryMatchMock()];
const result = getFormattedEntries(payload);
const expected: FormattedEntry[] = [
{
fieldName: 'host.name',
isNested: false,
operator: 'is one of',
value: ['some host name'],
},
{
fieldName: 'host.name',
isNested: false,
operator: 'is',
value: 'some host name',
},
];
expect(result).toEqual(expected);
});
test('it formats a mix of nested and non-nested entries as expected', () => {
const payload = getEntriesArrayMock();
const result = getFormattedEntries(payload);
const expected: FormattedEntry[] = [
{
fieldName: 'host.name',
isNested: false,
operator: 'is',
value: 'some host name',
},
{
fieldName: 'host.name',
isNested: false,
operator: 'is one of',
value: ['some host name'],
},
{
fieldName: 'host.name',
isNested: false,
operator: 'exists',
value: undefined,
},
{
fieldName: 'parent.field',
isNested: false,
operator: undefined,
value: undefined,
},
{
fieldName: 'host.name',
isNested: true,
operator: 'is',
value: 'some host name',
},
{
fieldName: 'host.name',
isNested: true,
operator: 'is one of',
value: ['some host name'],
},
];
expect(result).toEqual(expected);
});
});
describe('#formatEntry', () => {
test('it formats an entry', () => {
const payload = getEntryMatchMock();
const formattedEntry = formatEntry({ isNested: false, item: payload });
const expected: FormattedEntry = {
fieldName: 'host.name',
isNested: false,
operator: 'is',
value: 'some host name',
};
expect(formattedEntry).toEqual(expected);
});
test('it formats as expected when "isNested" is "true"', () => {
const payload = getEntryMatchMock();
const formattedEntry = formatEntry({ isNested: true, item: payload });
const expected: FormattedEntry = {
fieldName: 'host.name',
isNested: true,
operator: 'is',
value: 'some host name',
};
expect(formattedEntry).toEqual(expected);
});
});
describe('#getDescriptionListContent', () => {
test('it returns formatted description list with os if one is specified', () => {
const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] });
payload.description = '';
const result = getDescriptionListContent(payload);
const os = result.find(({ title }) => title === 'OS');
expect(os).toMatchInlineSnapshot(`
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="Linux"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
Linux
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "OS",
}
`);
});
test('it returns formatted description list with a description if one specified', () => {
const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] });
payload.description = 'Im a description';
const result = getDescriptionListContent(payload);
const description = result.find(({ title }) => title === 'Description');
expect(description).toMatchInlineSnapshot(`
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="Im a description"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
Im a description
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Description",
}
`);
});
test('it returns scrolling element when description is longer than 75 charachters', () => {
const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] });
payload.description =
'Puppy kitty ipsum dolor sit good dog foot stick canary. Teeth Mittens grooming vaccine walk swimming nest good boy furry tongue heel furry treats fish. Cage run fast kitten dinnertime ball run foot park fleas throw house train licks stick dinnertime window. Yawn litter fish yawn toy pet gate throw Buddy kitty wag tail ball groom crate ferret heel wet nose Rover toys pet supplies. Bird Food treats tongue lick teeth ferret litter box slobbery litter box crate bird small animals yawn small animals shake slobber gimme five toys polydactyl meow. ';
const result = getDescriptionListContent(payload);
const description = result.find(({ title }) => title === 'Description');
expect(description).toMatchInlineSnapshot(`
Object {
"description": <EuiDescriptionListDescription
style={
Object {
"height": 150,
"overflowY": "hidden",
}
}
>
<EuiText
aria-label=""
className="eui-yScrollWithShadows"
role="region"
size="s"
tabIndex={0}
>
Puppy kitty ipsum dolor sit good dog foot stick canary. Teeth Mittens grooming vaccine walk swimming nest good boy furry tongue heel furry treats fish. Cage run fast kitten dinnertime ball run foot park fleas throw house train licks stick dinnertime window. Yawn litter fish yawn toy pet gate throw Buddy kitty wag tail ball groom crate ferret heel wet nose Rover toys pet supplies. Bird Food treats tongue lick teeth ferret litter box slobbery litter box crate bird small animals yawn small animals shake slobber gimme five toys polydactyl meow.
</EuiText>
</EuiDescriptionListDescription>,
"title": "Description",
}
`);
});
test('it returns just user and date created if no other fields specified', () => {
const payload = getExceptionListItemSchemaMock();
payload.description = '';
const result = getDescriptionListContent(payload);
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="April 20th 2020 @ 15:25:31"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
April 20th 2020 @ 15:25:31
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Date created",
},
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="some user"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
some user
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Created by",
},
]
`);
});
test('it returns Modified By/On info when `includeModified` is true', () => {
const result = getDescriptionListContent(
getExceptionListItemSchemaMock({ os_types: ['linux'] }),
true
);
const dateModified = result.find(({ title }) => title === 'Date modified');
const modifiedBy = result.find(({ title }) => title === 'Modified by');
expect(modifiedBy).toMatchInlineSnapshot(`
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="some user"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
some user
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Modified by",
}
`);
expect(dateModified).toMatchInlineSnapshot(`
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="April 20th 2020 @ 15:25:31"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
April 20th 2020 @ 15:25:31
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Date modified",
}
`);
});
test('it returns Name when `includeName` is true', () => {
const result = getDescriptionListContent(
getExceptionListItemSchemaMock({ os_types: ['linux'] }),
false,
true
);
const name = result.find(({ title }) => title === 'Name');
expect(name).toMatchInlineSnapshot(`
Object {
"description": <EuiToolTip
anchorClassName="eventFiltersDescriptionListDescription"
content="some name"
delay="regular"
display="inlineBlock"
position="top"
>
<EuiDescriptionListDescription
className="eui-fullWidth"
>
some name
</EuiDescriptionListDescription>
</EuiToolTip>,
"title": "Name",
}
`);
});
});
});

View file

@ -1,166 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import { entriesNested, ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import {
getEntryValue,
getExceptionOperatorSelect,
BuilderEntry,
} from '@kbn/securitysolution-list-utils';
import React from 'react';
import { EuiDescriptionListDescription, EuiText, EuiToolTip } from '@elastic/eui';
import { formatOperatingSystems } from '../helpers';
import type { FormattedEntry, DescriptionListItem } from '../types';
import * as i18n from '../translations';
/**
* Helper method for `getFormattedEntries`
*/
export const formatEntry = ({
isNested,
item,
}: {
isNested: boolean;
item: BuilderEntry;
}): FormattedEntry => {
const operator = getExceptionOperatorSelect(item);
const value = getEntryValue(item);
return {
fieldName: item.field ?? '',
operator: operator.message,
value,
isNested,
};
};
/**
* 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: 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,
item: nestedEntry,
});
return [...acc, { ...formattedEntry }];
},
[parent]
);
} else {
return formatEntry({ isNested: false, item });
}
});
return formattedEntries.flat();
};
/**
* Formats ExceptionItem details for description list component
*
* @param exceptionItem an ExceptionItem
* @param includeModified if modified information should be included
* @param includeName if the Name should be included
*/
export const getDescriptionListContent = (
exceptionItem: ExceptionListItemSchema,
includeModified: boolean = false,
includeName: boolean = false
): DescriptionListItem[] => {
const details = [
...(includeName
? [
{
title: i18n.NAME,
value: exceptionItem.name,
},
]
: []),
{
title: i18n.OPERATING_SYSTEM,
value: formatOperatingSystems(exceptionItem.os_types),
},
{
title: i18n.DATE_CREATED,
value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'),
},
{
title: i18n.CREATED_BY,
value: exceptionItem.created_by,
},
...(includeModified
? [
{
title: i18n.DATE_MODIFIED,
value: moment(exceptionItem.updated_at).format('MMMM Do YYYY @ HH:mm:ss'),
},
{
title: i18n.MODIFIED_BY,
value: exceptionItem.updated_by,
},
]
: []),
{
title: i18n.DESCRIPTION,
value: exceptionItem.description,
},
];
return details.reduce<DescriptionListItem[]>((acc, { value, title }) => {
if (value != null && value.trim() !== '') {
const valueElement = (
<EuiToolTip content={value} anchorClassName="eventFiltersDescriptionListDescription">
<EuiDescriptionListDescription className="eui-fullWidth">
{value}
</EuiDescriptionListDescription>
</EuiToolTip>
);
if (title === i18n.DESCRIPTION) {
return [
...acc,
{
title,
description:
value.length > 75 ? (
<EuiDescriptionListDescription style={{ height: 150, overflowY: 'hidden' }}>
<EuiText
tabIndex={0}
role="region"
aria-label=""
className="eui-yScrollWithShadows"
size="s"
>
{value}
</EuiText>
</EuiDescriptionListDescription>
) : (
valueElement
),
},
];
}
return [...acc, { title, description: valueElement }];
} else {
return acc;
}
}, []);
};

View file

@ -260,7 +260,6 @@ const ExceptionsViewerComponent = ({
lists: exceptionListsMeta,
exception,
});
setCurrentModal('editException');
},
[setCurrentModal, exceptionListsMeta]
@ -328,8 +327,7 @@ const ExceptionsViewerComponent = ({
`security/detections/rules/id/${encodeURI(ruleId)}/edit`
);
const showEmpty: boolean =
!isInitLoading && !loadingList && totalEndpointItems === 0 && totalDetectionsItems === 0;
const showEmpty: boolean = !isInitLoading && !loadingList && exceptions.length === 0;
const showNoResults: boolean =
exceptions.length === 0 && (totalEndpointItems > 0 || totalDetectionsItems > 0);
@ -396,7 +394,6 @@ const ExceptionsViewerComponent = ({
isInitLoading={isInitLoading}
exceptions={exceptions}
loadingItemIds={loadingItemIds}
commentsAccordionId={commentsAccordionId}
onDeleteException={handleDeleteException}
onEditExceptionItem={handleEditException}
/>