[Security Solution] [Endpoint] Event filters uses the new card design (#114126)

* Adds new card design to event filters and also adds comments list

* Adds nested comments

* Hides comments if there are no commentes

* Fixes i18n check error because duplicated key

* Fix wrong type and unit test

* Fixes ts error

* Address pr comments and fix unit tests

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
David Sánchez 2021-10-13 12:03:13 +02:00 committed by GitHub
parent db4bcdee2c
commit 0bf0b94ee0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 329 additions and 77 deletions

View file

@ -25,7 +25,7 @@ describe.each([
) => ReturnType<AppContextTestRender['render']>;
beforeEach(() => {
item = generateItem();
item = generateItem() as AnyArtifact;
appTestContext = createAppRootMockRenderer();
render = (props = {}) => {
renderResult = appTestContext.render(
@ -77,13 +77,31 @@ describe.each([
expect(renderResult.getByTestId('testCard-description').textContent).toEqual(item.description);
});
it("shouldn't display description", async () => {
render({ hideDescription: true });
expect(renderResult.queryByTestId('testCard-description')).toBeNull();
});
it('should display default empty value if description does not exist', async () => {
item.description = undefined;
render();
expect(renderResult.getByTestId('testCard-description').textContent).toEqual('—');
});
it('should display comments if one exists', async () => {
render();
if (isTrustedApp(item)) {
expect(renderResult.queryByTestId('testCard-comments')).toBeNull();
} else {
expect(renderResult.queryByTestId('testCard-comments')).not.toBeNull();
}
});
it("shouldn't display comments", async () => {
render({ hideComments: true });
expect(renderResult.queryByTestId('testCard-comments')).toBeNull();
});
it('should display OS and criteria conditions', () => {
render();

View file

@ -16,10 +16,11 @@ import { useNormalizedArtifact } from './hooks/use_normalized_artifact';
import { useTestIdGenerator } from '../hooks/use_test_id_generator';
import { CardContainerPanel } from './components/card_container_panel';
import { CardSectionPanel } from './components/card_section_panel';
import { CardComments } from './components/card_comments';
import { usePolicyNavLinks } from './hooks/use_policy_nav_links';
import { MaybeImmutable } from '../../../../common/endpoint/types';
export interface ArtifactEntryCardProps extends CommonProps {
export interface CommonArtifactEntryCardProps extends CommonProps {
item: MaybeImmutable<AnyArtifact>;
/**
* The list of actions for the card. Will display an icon with the actions in a menu if defined.
@ -34,12 +35,27 @@ export interface ArtifactEntryCardProps extends CommonProps {
policies?: MenuItemPropsByPolicyId;
}
export interface ArtifactEntryCardProps extends CommonArtifactEntryCardProps {
// A flag to hide description section, false by default
hideDescription?: boolean;
// A flag to hide comments section, false by default
hideComments?: boolean;
}
/**
* Display Artifact Items (ex. Trusted App, Event Filter, etc) as a card.
* This component is a TS Generic that allows you to set what the Item type is
*/
export const ArtifactEntryCard = memo<ArtifactEntryCardProps>(
({ item, policies, actions, 'data-test-subj': dataTestSubj, ...commonProps }) => {
({
item,
policies,
actions,
hideDescription = false,
hideComments = false,
'data-test-subj': dataTestSubj,
...commonProps
}) => {
const artifact = useNormalizedArtifact(item as AnyArtifact);
const getTestId = useTestIdGenerator(dataTestSubj);
const policyNavLinks = usePolicyNavLinks(artifact, policies);
@ -63,11 +79,16 @@ export const ArtifactEntryCard = memo<ArtifactEntryCardProps>(
<EuiSpacer size="m" />
<EuiText>
<p data-test-subj={getTestId('description')}>
{artifact.description || getEmptyValue()}
</p>
</EuiText>
{!hideDescription ? (
<EuiText>
<p data-test-subj={getTestId('description')}>
{artifact.description || getEmptyValue()}
</p>
</EuiText>
) : null}
{!hideComments ? (
<CardComments comments={artifact.comments} data-test-subj={getTestId('comments')} />
) : null}
</CardSectionPanel>
<EuiHorizontalRule margin="none" />

View file

@ -7,7 +7,7 @@
import React, { memo } from 'react';
import { EuiHorizontalRule } from '@elastic/eui';
import { ArtifactEntryCardProps } from './artifact_entry_card';
import { CommonArtifactEntryCardProps } from './artifact_entry_card';
import { CardContainerPanel } from './components/card_container_panel';
import { useNormalizedArtifact } from './hooks/use_normalized_artifact';
import { useTestIdGenerator } from '../hooks/use_test_id_generator';
@ -15,7 +15,7 @@ import { CardSectionPanel } from './components/card_section_panel';
import { CriteriaConditions, CriteriaConditionsProps } from './components/criteria_conditions';
import { CardCompressedHeader } from './components/card_compressed_header';
export interface ArtifactEntryCollapsibleCardProps extends ArtifactEntryCardProps {
export interface ArtifactEntryCollapsibleCardProps extends CommonArtifactEntryCardProps {
onExpandCollapse: () => void;
expanded?: boolean;
}

View file

@ -0,0 +1,68 @@
/*
* 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, useState } from 'react';
import {
CommonProps,
EuiAccordion,
EuiCommentList,
EuiCommentProps,
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
import { CardActionsFlexItemProps } from './card_actions_flex_item';
import { ArtifactInfo } from '../types';
import { getFormattedComments } from '../utils/get_formatted_comments';
import { SHOW_COMMENTS_LABEL, HIDE_COMMENTS_LABEL } from './translations';
export interface CardCommentsProps
extends CardActionsFlexItemProps,
Pick<CommonProps, 'data-test-subj'> {
comments: ArtifactInfo['comments'];
}
export const CardComments = memo<CardCommentsProps>(
({ comments, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const [showComments, setShowComments] = useState(false);
const onCommentsClick = useCallback((): void => {
setShowComments(!showComments);
}, [setShowComments, showComments]);
const formattedComments = useMemo((): EuiCommentProps[] => {
return getFormattedComments(comments);
}, [comments]);
const buttonText = useMemo(
() =>
showComments ? HIDE_COMMENTS_LABEL(comments.length) : SHOW_COMMENTS_LABEL(comments.length),
[comments.length, showComments]
);
return !isEmpty(comments) ? (
<div data-test-subj={dataTestSubj}>
<EuiSpacer size="s" />
<EuiButtonEmpty
onClick={onCommentsClick}
flush="left"
size="xs"
data-test-subj={getTestId('label')}
>
{buttonText}
</EuiButtonEmpty>
<EuiAccordion id={'1'} arrowDisplay="none" forceState={showComments ? 'open' : 'closed'}>
<EuiSpacer size="m" />
<EuiCommentList comments={formattedComments} data-test-subj={getTestId('list')} />
</EuiAccordion>
</div>
) : null;
}
);
CardComments.displayName = 'CardComments';

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import React, { memo } from 'react';
import { CommonProps, EuiExpression } from '@elastic/eui';
import React, { memo, useCallback } from 'react';
import { CommonProps, EuiExpression, EuiToken, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { ListOperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import {
CONDITION_OS,
@ -21,7 +22,7 @@ import {
CONDITION_OPERATOR_TYPE_EXISTS,
CONDITION_OPERATOR_TYPE_LIST,
} from './translations';
import { ArtifactInfo } from '../types';
import { ArtifactInfo, ArtifactInfoEntry } from '../types';
import { useTestIdGenerator } from '../../hooks/use_test_id_generator';
const OS_LABELS = Object.freeze({
@ -39,6 +40,15 @@ const OPERATOR_TYPE_LABELS = Object.freeze({
[ListOperatorTypeEnum.LIST]: CONDITION_OPERATOR_TYPE_LIST,
});
const EuiFlexGroupNested = styled(EuiFlexGroup)`
margin-left: ${({ theme }) => theme.eui.spacerSizes.l};
`;
const EuiFlexItemNested = styled(EuiFlexItem)`
margin-bottom: 6px !important;
margin-top: 6px !important;
`;
export type CriteriaConditionsProps = Pick<ArtifactInfo, 'os' | 'entries'> &
Pick<CommonProps, 'data-test-subj'>;
@ -46,6 +56,44 @@ export const CriteriaConditions = memo<CriteriaConditionsProps>(
({ os, entries, 'data-test-subj': dataTestSubj }) => {
const getTestId = useTestIdGenerator(dataTestSubj);
const getNestedEntriesContent = useCallback(
(type: string, nestedEntries: ArtifactInfoEntry[]) => {
if (type === 'nested' && nestedEntries.length) {
return nestedEntries.map(
({ field: nestedField, type: nestedType, value: nestedValue }) => {
return (
<EuiFlexGroupNested
data-test-subj={getTestId('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={
OPERATOR_TYPE_LABELS[nestedType as keyof typeof OPERATOR_TYPE_LABELS] ??
nestedType
}
value={nestedValue}
/>
</EuiFlexItemNested>
</EuiFlexGroupNested>
);
}
);
}
},
[getTestId]
);
return (
<div data-test-subj={dataTestSubj}>
<div data-test-subj={getTestId('os')}>
@ -57,7 +105,7 @@ export const CriteriaConditions = memo<CriteriaConditionsProps>(
/>
</strong>
</div>
{entries.map(({ field, type, value }) => {
{entries.map(({ field, type, value, entries: nestedEntries = [] }) => {
return (
<div data-test-subj={getTestId('condition')} key={field + type + value}>
<EuiExpression description={CONDITION_AND} value={field} color="subdued" />
@ -67,6 +115,7 @@ export const CriteriaConditions = memo<CriteriaConditionsProps>(
}
value={value}
/>
{getNestedEntriesContent(type, nestedEntries)}
</div>
);
})}

View file

@ -114,3 +114,15 @@ export const COLLAPSE_ACTION = i18n.translate(
defaultMessage: 'Collapse',
}
);
export const SHOW_COMMENTS_LABEL = (count: number = 0) =>
i18n.translate('xpack.securitySolution.artifactCard.comments.label.show', {
defaultMessage: 'Show comments ({count})',
values: { count },
});
export const HIDE_COMMENTS_LABEL = (count: number = 0) =>
i18n.translate('xpack.securitySolution.artifactCard.comments.label.hide', {
defaultMessage: 'Hide comments ({count})',
values: { count },
});

View file

@ -6,6 +6,7 @@
*/
import { cloneDeep } from 'lodash';
import uuid from 'uuid';
import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
import { TrustedAppGenerator } from '../../../../common/endpoint/data_generators/trusted_app_generator';
import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock';
@ -54,6 +55,14 @@ export const getExceptionProviderMock = (): ExceptionListItemSchema => {
},
],
tags: ['policy:all'],
comments: [
{
id: uuid.v4(),
comment: 'test',
created_at: new Date().toISOString(),
created_by: 'Justa',
},
],
})
);
};

View file

@ -10,6 +10,13 @@ import { EffectScope, TrustedApp } from '../../../../common/endpoint/types';
import { ContextMenuItemNavByRouterProps } from '../context_menu_with_router_support/context_menu_item_nav_by_router';
export type AnyArtifact = ExceptionListItemSchema | TrustedApp;
export interface ArtifactInfoEntry {
field: string;
type: string;
operator: string;
value: string;
}
type ArtifactInfoEntries = ArtifactInfoEntry & { entries?: ArtifactInfoEntry[] };
/**
* A normalized structured that is used internally through out the card's components.
@ -17,16 +24,11 @@ export type AnyArtifact = ExceptionListItemSchema | TrustedApp;
export interface ArtifactInfo
extends Pick<
ExceptionListItemSchema,
'name' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by' | 'description'
'name' | 'created_at' | 'updated_at' | 'created_by' | 'updated_by' | 'description' | 'comments'
> {
effectScope: EffectScope;
os: string;
entries: Array<{
field: string;
type: string;
operator: string;
value: string;
}>;
entries: ArtifactInfoEntries[];
}
export interface MenuItemPropsByPolicyId {

View file

@ -0,0 +1,34 @@
/*
* 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 { EuiAvatar, EuiText, EuiCommentProps } from '@elastic/eui';
import styled from 'styled-components';
import { CommentsArray } from '@kbn/securitysolution-io-ts-list-types';
import { COMMENT_EVENT } from '../../../../common/components/exceptions/translations';
import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date';
const CustomEuiAvatar = styled(EuiAvatar)`
background-color: ${({ theme }) => theme.eui.euiColorLightShade} !important;
`;
/**
* Formats ExceptionItem.comments into EuiCommentList format
*
* @param comments ExceptionItem.comments
*/
export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] => {
return comments.map((commentItem) => ({
username: commentItem.created_by,
timestamp: (
<FormattedRelativePreferenceDate value={commentItem.created_at} dateFormat="MMM D, YYYY" />
),
event: COMMENT_EVENT,
timelineIcon: <CustomEuiAvatar size="s" name={commentItem.created_by} />,
children: <EuiText size="s">{commentItem.comment}</EuiText>,
}));
};

View file

@ -24,6 +24,7 @@ export const mapToArtifactInfo = (_item: MaybeImmutable<AnyArtifact>): ArtifactI
updated_at,
updated_by,
description,
comments: isTrustedApp(item) ? [] : item.comments,
entries: entries as unknown as ArtifactInfo['entries'],
os: isTrustedApp(item) ? item.os : getOsFromExceptionItem(item),
effectScope: isTrustedApp(item) ? item.effectScope : getEffectScopeFromExceptionItem(item),

View file

@ -109,19 +109,15 @@ describe('When on the Event Filters List Page', () => {
it('should render expected fields on card', async () => {
render();
await dataReceived();
const eventMeta = ([] as HTMLElement[]).map.call(
renderResult.getByTestId('exceptionsViewerItemDetails').querySelectorAll('dd'),
(ele) => ele.textContent
);
expect(eventMeta).toEqual([
'some name',
'April 20th 2020 @ 11:25:31',
'some user',
'April 20th 2020 @ 11:25:31',
'some user',
'some description',
]);
[
['subHeader-touchedBy-createdBy-value', 'some user'],
['subHeader-touchedBy-updatedBy-value', 'some user'],
['header-created-value', '4/20/2020'],
['header-updated-value', '4/20/2020'],
].forEach(([suffix, value]) =>
expect(renderResult.getByTestId(`eventFilterCard-${suffix}`).textContent).toEqual(value)
);
});
it('should show API error if one is encountered', async () => {
@ -143,8 +139,13 @@ describe('When on the Event Filters List Page', () => {
it('should show modal when delete is clicked on a card', async () => {
render();
await dataReceived();
act(() => {
fireEvent.click(renderResult.getByTestId('exceptionsViewerDeleteBtn'));
await act(async () => {
(await renderResult.findAllByTestId('eventFilterCard-header-actions-button'))[0].click();
});
await act(async () => {
(await renderResult.findByTestId('deleteEventFilterAction')).click();
});
expect(

View file

@ -36,16 +36,20 @@ import {
} from '../store/selector';
import { PaginatedContent, PaginatedContentProps } from '../../../components/paginated_content';
import { Immutable, ListPageRouteState } from '../../../../../common/endpoint/types';
import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item';
import {
ExceptionItem,
ExceptionItemProps,
} from '../../../../common/components/exceptions/viewer/exception_item';
AnyArtifact,
ArtifactEntryCard,
ArtifactEntryCardProps,
} from '../../../components/artifact_entry_card';
import { EventFilterDeleteModal } from './components/event_filter_delete_modal';
import { SearchExceptions } from '../../../components/search_exceptions';
import { BackToExternalAppButton } from '../../../components/back_to_external_app_button';
import { ABOUT_EVENT_FILTERS } from './translations';
type ArtifactEntryCardType = typeof ArtifactEntryCard;
type EventListPaginatedContent = PaginatedContentProps<
Immutable<ExceptionListItemSchema>,
typeof ExceptionItem
@ -61,6 +65,20 @@ const AdministrationListPage = styled(_AdministrationListPage)`
}
`;
const EDIT_EVENT_FILTER_ACTION_LABEL = i18n.translate(
'xpack.securitySolution.eventFilters.list.cardAction.edit',
{
defaultMessage: 'Edit event filter',
}
);
const DELETE_EVENT_FILTER_ACTION_LABEL = i18n.translate(
'xpack.securitySolution.eventFilters.list.cardAction.delete',
{
defaultMessage: 'Delete event filter',
}
);
export const EventFiltersListPage = memo(() => {
const { state: routeState } = useLocation<ListPageRouteState | undefined>();
const history = useHistory();
@ -133,41 +151,6 @@ export const EventFiltersListPage = memo(() => {
[navigateCallback]
);
const handleItemEdit: ExceptionItemProps['onEditException'] = useCallback(
(item: ExceptionListItemSchema) => {
navigateCallback({
show: 'edit',
id: item.id,
});
},
[navigateCallback]
);
const handleItemDelete: ExceptionItemProps['onDeleteException'] = useCallback(
({ id }) => {
dispatch({
type: 'eventFilterForDeletion',
// Casting below needed due to error around the comments array needing to be mutable
payload: listItems.find((item) => item.id === id)! as ExceptionListItemSchema,
});
},
[dispatch, listItems]
);
const handleItemComponentProps: EventListPaginatedContent['itemComponentProps'] = useCallback(
(exceptionItem) => ({
exceptionItem: exceptionItem as ExceptionListItemSchema,
loadingItemIds: [],
commentsAccordionId: '',
onEditException: handleItemEdit,
onDeleteException: handleItemDelete,
showModified: true,
showName: true,
'data-test-subj': `eventFilterCard`,
}),
[handleItemDelete, handleItemEdit]
);
const handlePaginatedContentChange: EventListPaginatedContent['onChange'] = useCallback(
({ pageIndex, pageSize }) => {
navigateCallback({
@ -186,6 +169,59 @@ export const EventFiltersListPage = memo(() => {
[navigateCallback, dispatch]
);
const artifactCardPropsPerItem = useMemo(() => {
const cachedCardProps: Record<string, ArtifactEntryCardProps> = {};
// Casting `listItems` below to remove the `Immutable<>` from it in order to prevent errors
// with common component's props
for (const eventFilter of listItems as ExceptionListItemSchema[]) {
let policies: ArtifactEntryCardProps['policies'];
cachedCardProps[eventFilter.id] = {
item: eventFilter as AnyArtifact,
policies,
hideDescription: true,
'data-test-subj': 'eventFilterCard',
actions: [
{
icon: 'controlsHorizontal',
onClick: () => {
history.push(
getEventFiltersListPath({
...location,
show: 'edit',
id: eventFilter.id,
})
);
},
'data-test-subj': 'editEventFilterAction',
children: EDIT_EVENT_FILTER_ACTION_LABEL,
},
{
icon: 'trash',
onClick: () => {
dispatch({
type: 'eventFilterForDeletion',
payload: eventFilter,
});
},
'data-test-subj': 'deleteEventFilterAction',
children: DELETE_EVENT_FILTER_ACTION_LABEL,
},
],
};
}
return cachedCardProps;
}, [dispatch, history, listItems, location]);
const handleArtifactCardProps = useCallback(
(eventFilter: ExceptionListItemSchema) => {
return artifactCardPropsPerItem[eventFilter.id];
},
[artifactCardPropsPerItem]
);
return (
<AdministrationListPage
headerBackComponent={backButton}
@ -244,10 +280,10 @@ export const EventFiltersListPage = memo(() => {
</>
)}
<PaginatedContent<Immutable<ExceptionListItemSchema>, typeof ExceptionItem>
<PaginatedContent<ExceptionListItemSchema, ArtifactEntryCardType>
items={listItems}
ItemComponent={ExceptionItem}
itemComponentProps={handleItemComponentProps}
ItemComponent={ArtifactEntryCard}
itemComponentProps={handleArtifactCardProps}
onChange={handlePaginatedContentChange}
error={fetchError?.message}
loading={isLoading}

View file

@ -138,6 +138,7 @@ export const TrustedAppsGrid = memo(() => {
cachedCardProps[trustedApp.id] = {
item: trustedApp,
policies,
hideComments: true,
'data-test-subj': 'trustedAppCard',
actions: [
{