Skip to content

Commit

Permalink
[Security Solution][Detections] Improves UX for Rules and Exceptions …
Browse files Browse the repository at this point in the history
…tables (#118940)

[Security Solution][Detections] Improves UX for Rules and Exceptions tables
  • Loading branch information
vitaliidm authored Nov 24, 2021
1 parent d99d455 commit 7927bd4
Show file tree
Hide file tree
Showing 15 changed files with 536 additions and 433 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import { isEmpty } from 'lodash';
import { isEmpty, capitalize } from 'lodash';

import type {
EntriesArray,
Expand Down Expand Up @@ -73,3 +73,11 @@ export const getRuleStatusText = (
: value != null
? value
: null;

export const getCapitalizedRuleStatusText = (
value: RuleExecutionStatus | null | undefined
): string | null => {
const status = getRuleStatusText(value);

return status != null ? capitalize(status) : null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* 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 { render, screen } from '@testing-library/react';

import { HealthTruncateText } from '.';

describe('Component HealthTruncateText', () => {
it('should render component without errors', () => {
render(<HealthTruncateText dataTestSubj="testItem">{'Test'}</HealthTruncateText>);

expect(screen.getByTestId('testItem')).toHaveTextContent('Test');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* 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 { EuiHealth, EuiToolTip, EuiHealthProps } from '@elastic/eui';
import styled from 'styled-components';

const StatusTextWrapper = styled.div`
width: 100%;
display: inline-grid;
`;

interface HealthTruncateTextProps {
healthColor?: EuiHealthProps['color'];
tooltipContent?: React.ReactNode;
dataTestSubj?: string;
}

/**
* Allows text in EuiHealth to be properly truncated with tooltip
* @param healthColor - color for EuiHealth component
* @param tooltipContent - tooltip content
*/
export const HealthTruncateText: React.FC<HealthTruncateTextProps> = ({
tooltipContent,
children,
healthColor,
dataTestSubj,
}) => (
<EuiToolTip content={tooltipContent}>
<EuiHealth color={healthColor} data-test-subj={dataTestSubj}>
<StatusTextWrapper>
<span className="eui-textTruncate">{children}</span>
</StatusTextWrapper>
</EuiHealth>
</EuiToolTip>
);

HealthTruncateText.displayName = 'HealthTruncateText';
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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 { PopoverItems, PopoverItemsProps } from '.';
import { TestProviders } from '../../mock';
import { render, screen } from '@testing-library/react';
import { within } from '@testing-library/dom';

const mockTags = ['Elastic', 'Endpoint', 'Data Protection', 'ML', 'Continuous Monitoring'];

const renderHelper = (props: Partial<PopoverItemsProps<string>> = {}) =>
render(
<TestProviders>
<PopoverItems
dataTestPrefix="tags"
items={mockTags}
popoverButtonTitle="show mocks"
renderItem={(item: string, index: number) => <span key={`${item}-${index}`}>{item}</span>}
{...props}
/>
</TestProviders>
);

const getButton = () => screen.getByRole('button', { name: 'show mocks' });
const withinPopover = () => within(screen.getByTestId('tagsDisplayPopoverWrapper'));

describe('Component PopoverItems', () => {
it('shoud render only 2 first items in display and rest in popup', async () => {
renderHelper({ numberOfItemsToDisplay: 2 });
mockTags.slice(0, 2).forEach((tag) => {
expect(screen.getByText(tag)).toBeInTheDocument();
});

// items not rendered yet
mockTags.slice(2).forEach((tag) => {
expect(screen.queryByText(tag)).toBeNull();
});

getButton().click();
expect(await screen.findByTestId('tagsDisplayPopoverWrapper')).toBeInTheDocument();

// items rendered in popup
mockTags.slice(2).forEach((tag) => {
expect(withinPopover().getByText(tag)).toBeInTheDocument();
});
});

it('shoud render popover button and items in popover without popover title', () => {
renderHelper();
mockTags.forEach((tag) => {
expect(screen.queryByText(tag)).toBeNull();
});
getButton().click();

mockTags.forEach((tag) => {
expect(withinPopover().queryByText(tag)).toBeInTheDocument();
});

expect(screen.queryByTestId('tagsDisplayPopoverTitle')).toBeNull();
});

it('shoud render popover title', async () => {
renderHelper({ popoverTitle: 'Tags popover title' });

getButton().click();

expect(await screen.findByTestId('tagsDisplayPopoverWrapper')).toBeInTheDocument();
expect(screen.getByTestId('tagsDisplayPopoverTitle')).toHaveTextContent('Tags popover title');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/*
* 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, { useState } from 'react';
import {
EuiPopover,
EuiBadgeGroup,
EuiBadge,
EuiPopoverTitle,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import styled from 'styled-components';

export interface PopoverItemsProps<T> {
renderItem: (item: T, index: number, items: T[]) => React.ReactNode;
items: T[];
popoverButtonTitle: string;
popoverButtonIcon?: string;
popoverTitle?: string;
numberOfItemsToDisplay?: number;
dataTestPrefix?: string;
}

interface OverflowListProps<T> {
readonly items: T[];
}

const PopoverItemsWrapper = styled(EuiFlexGroup)`
width: 100%;
`;

const PopoverWrapper = styled(EuiBadgeGroup)`
max-height: 200px;
max-width: 600px;
overflow: auto;
line-height: ${({ theme }) => theme.eui.euiLineHeight};
`;

/**
* Component to render list of items in popover, wicth configurabe number of display items by default
* @param items - array of items to render
* @param renderItem - render function that render item, arguments: item, index, items[]
* @param popoverTitle - title of popover
* @param popoverButtonTitle - title of popover button that triggers popover
* @param popoverButtonIcon - icon of popover button that triggers popover
* @param numberOfItemsToDisplay - number of items to render that are no in popover, defaults to 0
* @param dataTestPrefix - data-test-subj prefix to apply to elements
*/
const PopoverItemsComponent = <T extends unknown>({
items,
renderItem,
popoverTitle,
popoverButtonTitle,
popoverButtonIcon,
numberOfItemsToDisplay = 0,
dataTestPrefix = 'items',
}: PopoverItemsProps<T>) => {
const [isExceptionOverflowPopoverOpen, setIsExceptionOverflowPopoverOpen] = useState(false);

const OverflowList = ({ items: itemsToRender }: OverflowListProps<T>) => (
<>{itemsToRender.map(renderItem)}</>
);

if (items.length <= numberOfItemsToDisplay) {
return (
<PopoverItemsWrapper data-test-subj={dataTestPrefix} alignItems="center" gutterSize="s">
<OverflowList items={items} />
</PopoverItemsWrapper>
);
}

return (
<PopoverItemsWrapper alignItems="center" gutterSize="s" data-test-subj={dataTestPrefix}>
<EuiFlexItem grow={1} className="eui-textTruncate">
<OverflowList items={items.slice(0, numberOfItemsToDisplay)} />
</EuiFlexItem>
<EuiPopover
ownFocus
data-test-subj={`${dataTestPrefix}DisplayPopover`}
button={
<EuiBadge
iconType={popoverButtonIcon}
color="hollow"
data-test-subj={`${dataTestPrefix}DisplayPopoverButton`}
onClick={() => setIsExceptionOverflowPopoverOpen(!isExceptionOverflowPopoverOpen)}
onClickAriaLabel={popoverButtonTitle}
>
{popoverButtonTitle}
</EuiBadge>
}
isOpen={isExceptionOverflowPopoverOpen}
closePopover={() => setIsExceptionOverflowPopoverOpen(!isExceptionOverflowPopoverOpen)}
repositionOnScroll
>
{popoverTitle ? (
<EuiPopoverTitle data-test-subj={`${dataTestPrefix}DisplayPopoverTitle`}>
{popoverTitle}
</EuiPopoverTitle>
) : null}
<PopoverWrapper data-test-subj={`${dataTestPrefix}DisplayPopoverWrapper`}>
<OverflowList items={items.slice(numberOfItemsToDisplay)} />
</PopoverWrapper>
</EuiPopover>
</PopoverItemsWrapper>
);
};

const MemoizedPopoverItems = React.memo(PopoverItemsComponent);
MemoizedPopoverItems.displayName = 'PopoverItems';

export const PopoverItems = MemoizedPopoverItems as typeof PopoverItemsComponent;
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 { render, screen } from '@testing-library/react';

import { RuleExecutionStatusBadge } from '.';

import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas';

describe('Component RuleExecutionStatus', () => {
it('should render component correctly with capitalized status text', () => {
render(<RuleExecutionStatusBadge status={RuleExecutionStatus.succeeded} />);

expect(screen.getByText('Succeeded')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 { getEmptyTagValue } from '../../../../common/components/empty_value';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
import { getStatusColor } from '../rule_status/helpers';

import { getCapitalizedRuleStatusText } from '../../../../../common/detection_engine/utils';
import type { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas';

interface RuleExecutionStatusBadgeProps {
status: RuleExecutionStatus | null | undefined;
}

/**
* Shows rule execution status
* @param status - rule execution status
*/
const RuleExecutionStatusBadgeComponent = ({ status }: RuleExecutionStatusBadgeProps) => {
const displayStatus = getCapitalizedRuleStatusText(status);
return (
<HealthTruncateText
tooltipContent={displayStatus}
healthColor={getStatusColor(status ?? null)}
dataTestSubj="ruleExecutionStatus"
>
{displayStatus ?? getEmptyTagValue()}
</HealthTruncateText>
);
};

export const RuleExecutionStatusBadge = React.memo(RuleExecutionStatusBadgeComponent);

RuleExecutionStatusBadge.displayName = 'RuleExecutionStatusBadge';
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ describe('SeverityBadge', () => {
it('renders correctly', () => {
const wrapper = shallow(<SeverityBadge value="low" />);

expect(wrapper.find('EuiHealth')).toHaveLength(1);
expect(wrapper.find('HealthTruncateText')).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,28 @@

import { upperFirst } from 'lodash/fp';
import React from 'react';
import { EuiHealth } from '@elastic/eui';
import { euiLightVars } from '@kbn/ui-shared-deps-src/theme';

import { euiLightVars } from '@kbn/ui-shared-deps-src/theme';
import { HealthTruncateText } from '../../../../common/components/health_truncate_text';
interface Props {
value: string;
}

const SeverityBadgeComponent: React.FC<Props> = ({ value }) => (
<EuiHealth
data-test-subj="severity"
color={
value === 'low'
? euiLightVars.euiColorVis0
: value === 'medium'
? euiLightVars.euiColorVis5
: value === 'high'
? euiLightVars.euiColorVis7
: euiLightVars.euiColorVis9
}
>
{upperFirst(value)}
</EuiHealth>
);
const SeverityBadgeComponent: React.FC<Props> = ({ value }) => {
const displayValue = upperFirst(value);
const color = 'low'
? euiLightVars.euiColorVis0
: value === 'medium'
? euiLightVars.euiColorVis5
: value === 'high'
? euiLightVars.euiColorVis7
: euiLightVars.euiColorVis9;

return (
<HealthTruncateText healthColor={color} tooltipContent={displayValue} dataTestSubj="severity">
{displayValue}
</HealthTruncateText>
);
};

export const SeverityBadge = React.memo(SeverityBadgeComponent);
Loading

0 comments on commit 7927bd4

Please sign in to comment.