[Security Solution] Changes rules table tag display (#77102)

This commit is contained in:
Davis Plumlee 2020-10-02 16:18:55 -06:00 committed by GitHub
parent ed10d9f8dd
commit bd80d3c747
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 248 additions and 59 deletions

View file

@ -7,7 +7,6 @@
/* eslint-disable react/display-name */
import {
EuiBadge,
EuiBasicTableColumn,
EuiTableActionsColumnType,
EuiText,
@ -24,7 +23,6 @@ import { getEmptyTagValue } from '../../../../../common/components/empty_value';
import { FormattedDate } from '../../../../../common/components/formatted_date';
import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine';
import { ActionToaster } from '../../../../../common/components/toasters';
import { TruncatableText } from '../../../../../common/components/truncatable_text';
import { getStatusColor } from '../../../../components/rules/rule_status/helpers';
import { RuleSwitch } from '../../../../components/rules/rule_switch';
import { SeverityBadge } from '../../../../components/rules/severity_badge';
@ -39,6 +37,7 @@ import { Action } from './reducer';
import { LocalizedDateTooltip } from '../../../../../common/components/localized_date_tooltip';
import * as detectionI18n from '../../translations';
import { LinkAnchor } from '../../../../../common/components/links';
import { TagsDisplay } from './tag_display';
export const getActions = (
dispatch: React.Dispatch<Action>,
@ -207,22 +206,19 @@ export const getColumns = ({
);
},
truncateText: true,
width: '10%',
width: '8%',
},
{
field: 'tags',
name: i18n.COLUMN_TAGS,
render: (value: Rule['tags']) => (
<TruncatableText data-test-subj="tags">
{value.map((tag, i) => (
<EuiBadge color="hollow" key={`${tag}-${i}`}>
{tag}
</EuiBadge>
))}
</TruncatableText>
),
render: (value: Rule['tags']) => {
if (value.length > 0) {
return <TagsDisplay tags={value} />;
}
return getEmptyTagValue();
},
truncateText: true,
width: '14%',
width: '20%',
},
{
align: 'center',

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { bucketRulesResponse, showRulesTable } from './helpers';
import { bucketRulesResponse, caseInsensitiveSort, showRulesTable } from './helpers';
import { mockRule, mockRuleError } from './__mocks__/mock';
import uuid from 'uuid';
import { Rule, RuleError } from '../../../../containers/detection_engine/rules';
@ -86,4 +86,15 @@ describe('AllRulesTable Helpers', () => {
expect(result).toBeTruthy();
});
});
describe('caseInsensitiveSort', () => {
describe('when an array of differently cased tags is passed', () => {
const unsortedTags = ['atest', 'Ctest', 'Btest', 'ctest', 'btest', 'Atest'];
const result = caseInsensitiveSort(unsortedTags);
it('returns an alphabetically sorted array with no regard for casing', () => {
const expected = ['atest', 'Atest', 'Btest', 'btest', 'Ctest', 'ctest'];
expect(result).toEqual(expected);
});
});
});
});

View file

@ -33,3 +33,7 @@ export const showRulesTable = ({
}) =>
(rulesCustomInstalled != null && rulesCustomInstalled > 0) ||
(rulesInstalled != null && rulesInstalled > 0);
export const caseInsensitiveSort = (tags: string[]): string[] => {
return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive
};

View file

@ -5,7 +5,8 @@
*/
import React from 'react';
import { shallow, mount } from 'enzyme';
import { shallow, mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import '../../../../../common/mock/match_media';
import '../../../../../common/mock/formatted_relative';
@ -179,27 +180,34 @@ describe('AllRules', () => {
expect(wrapper.find('[title="All rules"]')).toHaveLength(1);
});
it('renders rules tab', async () => {
const wrapper = mount(
<TestProviders>
<AllRules
createPrePackagedRules={jest.fn()}
hasNoPermissions={false}
loading={false}
loadingCreatePrePackagedRules={false}
refetchPrePackagedRulesStatus={jest.fn()}
rulesCustomInstalled={1}
rulesInstalled={0}
rulesNotInstalled={0}
rulesNotUpdated={0}
setRefreshRulesData={jest.fn()}
/>
</TestProviders>
);
describe('rules tab', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
wrapper = mount(
<TestProviders>
<AllRules
createPrePackagedRules={jest.fn()}
hasNoPermissions={false}
loading={false}
loadingCreatePrePackagedRules={false}
refetchPrePackagedRulesStatus={jest.fn()}
rulesCustomInstalled={1}
rulesInstalled={0}
rulesNotInstalled={0}
rulesNotUpdated={0}
setRefreshRulesData={jest.fn()}
/>
</TestProviders>
);
});
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy();
expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy();
it('renders correctly', async () => {
await act(async () => {
await waitFor(() => {
expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy();
expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy();
});
});
});
});

View file

@ -27,6 +27,7 @@ import {
import styled from 'styled-components';
import * as i18n from '../../translations';
import { toggleSelectedGroup } from '../../../../../../common/components/ml_popover/jobs_table/filters/toggle_selected_group';
import { caseInsensitiveSort } from '../helpers';
interface TagsFilterPopoverProps {
selectedTags: string[];
@ -36,9 +37,19 @@ interface TagsFilterPopoverProps {
isLoading: boolean; // TO DO reimplement?
}
const PopoverContentWrapper = styled.div`
width: 275px;
`;
const ScrollableDiv = styled.div`
max-height: 250px;
overflow: auto;
overflow-y: auto;
`;
const TagOverflowContainer = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
/**
@ -52,9 +63,7 @@ const TagsFilterPopoverComponent = ({
selectedTags,
onSelectedTagsChanged,
}: TagsFilterPopoverProps) => {
const sortedTags = useMemo(() => {
return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive
}, [tags]);
const sortedTags = useMemo(() => caseInsensitiveSort(tags), [tags]);
const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [filterTags, setFilterTags] = useState(sortedTags);
@ -65,8 +74,9 @@ const TagsFilterPopoverComponent = ({
checked={selectedTags.includes(tag) ? 'on' : undefined}
key={`${index}-${tag}`}
onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)}
title={tag}
>
{`${tag}`}
<TagOverflowContainer>{tag}</TagOverflowContainer>
</EuiFilterSelectItem>
));
}, [onSelectedTagsChanged, selectedTags, filterTags]);
@ -101,25 +111,27 @@ const TagsFilterPopoverComponent = ({
panelPaddingSize="none"
repositionOnScroll
>
<EuiPopoverTitle>
<EuiFieldSearch
placeholder="Search tags"
value={searchInput}
onChange={onSearchInputChange}
isClearable
aria-label="Rules tag search"
/>
</EuiPopoverTitle>
<ScrollableDiv>{tagsComponent}</ScrollableDiv>
{filterTags.length === 0 && (
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
<EuiFlexItem grow={true}>
<EuiPanel>
<EuiText>{i18n.NO_TAGS_AVAILABLE}</EuiText>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
)}
<PopoverContentWrapper>
<EuiPopoverTitle>
<EuiFieldSearch
placeholder="Search tags"
value={searchInput}
onChange={onSearchInputChange}
isClearable
aria-label="Rules tag search"
/>
</EuiPopoverTitle>
<ScrollableDiv>{tagsComponent}</ScrollableDiv>
{filterTags.length === 0 && (
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
<EuiFlexItem grow={true}>
<EuiPanel>
<EuiText>{i18n.NO_TAGS_AVAILABLE}</EuiText>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
)}
</PopoverContentWrapper>
</EuiPopover>
);
};

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { TagsDisplay } from './tag_display';
import { TestProviders } from '../../../../../common/mock';
import { waitFor } from '@testing-library/react';
const mockTags = ['Elastic', 'Endpoint', 'Data Protection', 'ML', 'Continuous Monitoring'];
describe('When tag display loads', () => {
let wrapper: ReactWrapper;
beforeEach(() => {
wrapper = mount(
<TestProviders>
<TagsDisplay tags={mockTags} />
</TestProviders>
);
});
it('visibly renders 3 initial tags', () => {
for (let i = 0; i < 3; i++) {
expect(wrapper.exists(`[data-test-subj="rules-table-column-tags-${i}"]`)).toBeTruthy();
}
});
describe("when the 'see all' button is clicked", () => {
beforeEach(() => {
const seeAllButton = wrapper.find('[data-test-subj="tags-display-popover-button"] button');
seeAllButton.simulate('click');
});
it('renders all the tags in the popover', async () => {
await waitFor(() => {
wrapper.update();
expect(wrapper.exists('[data-test-subj="tags-display-popover"]')).toBeTruthy();
for (let i = 0; i < mockTags.length; i++) {
expect(
wrapper.exists(`[data-test-subj="rules-table-column-popover-tags-${i}"]`)
).toBeTruthy();
}
});
});
});
});

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useState } from 'react';
import { EuiPopover, EuiBadgeGroup, EuiBadge, EuiButtonEmpty } from '@elastic/eui';
import styled from 'styled-components';
import * as i18n from '../translations';
import { caseInsensitiveSort } from './helpers';
interface TagsDisplayProps {
tags: string[];
}
const TagWrapper = styled(EuiBadgeGroup)`
width: 100%;
`;
const TagPopoverWrapper = styled(EuiBadgeGroup)`
max-height: 200px;
max-width: 600px;
overflow: auto;
`;
const TagPopoverButton = styled(EuiButtonEmpty)`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS}
font-weight: 500;
height: 20px;
`;
/**
* @param tags to display for filtering
*/
const TagsDisplayComponent = ({ tags }: TagsDisplayProps) => {
const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false);
const sortedTags = useMemo(() => caseInsensitiveSort(tags), [tags]);
return (
<>
{sortedTags.length <= 3 ? (
<TagWrapper data-test-subj="tags">
{sortedTags.map((tag: string, i: number) => (
<EuiBadge
color="hollow"
key={`${tag}-${i}`}
data-test-subj={`rules-table-column-tags-${i}`}
>
{tag}
</EuiBadge>
))}
</TagWrapper>
) : (
<TagWrapper data-test-subj="tags">
{sortedTags.slice(0, 3).map((tag: string, i: number) => (
<EuiBadge
color="hollow"
key={`${tag}-${i}`}
data-test-subj={`rules-table-column-tags-${i}`}
>
{tag}
</EuiBadge>
))}
<EuiPopover
ownFocus
display="block"
data-test-subj="tags-display-popover"
button={
<TagPopoverButton
size="xs"
data-test-subj={'tags-display-popover-button'}
onClick={() => setIsTagPopoverOpen(!isTagPopoverOpen)}
>
{i18n.COLUMN_SEE_ALL_POPOVER}
</TagPopoverButton>
}
isOpen={isTagPopoverOpen}
closePopover={() => setIsTagPopoverOpen(!isTagPopoverOpen)}
repositionOnScroll
>
<TagPopoverWrapper>
{sortedTags.map((tag: string, i: number) => (
<EuiBadge
color="hollow"
key={`${tag}-${i}`}
data-test-subj={`rules-table-column-popover-tags-${i}`}
title={tag}
>
{tag}
</EuiBadge>
))}
</TagPopoverWrapper>
</EuiPopover>
</TagWrapper>
)}
</>
);
};
export const TagsDisplay = React.memo(TagsDisplayComponent);
TagsDisplay.displayName = 'TagsDisplay';

View file

@ -315,6 +315,13 @@ export const COLUMN_TAGS = i18n.translate(
}
);
export const COLUMN_SEE_ALL_POPOVER = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsPopoverTitle',
{
defaultMessage: 'See all',
}
);
export const COLUMN_ACTIVATE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.columns.activateTitle',
{