mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] Changes rules table tag display (#77102)
This commit is contained in:
parent
ed10d9f8dd
commit
bd80d3c747
8 changed files with 248 additions and 59 deletions
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue