Skip to content

Commit

Permalink
[Security Solution] Migrate rules table tags filter to EuiSelectable (e…
Browse files Browse the repository at this point in the history
…lastic#149508)

**Relates to**: elastic#140263

## Summary

This PR migrates custom tags selector implementation on the rules page which mimics EuiSelectable to **EuiSelectable**. Besides simplification it brings keyboard and accessibility support as well as simplifies accessing the component in e2e tests.

*Before:*

https://user-images.githubusercontent.com/3775283/214831542-737aa9cf-8f76-4777-a23f-cbbfe0a01825.mov

*After:*

https://user-images.githubusercontent.com/3775283/214831568-e0809fd7-3c17-4789-8d3a-9ecbe379fb56.mov

### Checklist

- [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
  • Loading branch information
maximpn authored and darnautov committed Feb 7, 2023
1 parent bce6f54 commit a873621
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 99 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,6 @@ describe('Detection rules, bulk edit', () => {
// check if only pre-populated tags exist in the tags filter
checkTagsInTagsFilter(prePopulatedTags);

cy.get(EUI_FILTER_SELECT_ITEM)
.should('have.length', prePopulatedTags.length)
.each(($el, index) => {
cy.wrap($el).should('have.text', prePopulatedTags[index]);
});

selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);

// open add tags form and add 2 new tags
Expand All @@ -296,12 +290,6 @@ describe('Detection rules, bulk edit', () => {
// check if only pre-populated tags exist in the tags filter
checkTagsInTagsFilter(prePopulatedTags);

cy.get(EUI_FILTER_SELECT_ITEM)
.should('have.length', prePopulatedTags.length)
.each(($el, index) => {
cy.wrap($el).should('have.text', prePopulatedTags[index]);
});

selectNumberOfRules(expectedNumberOfCustomRulesToBeEdited);

// open add tags form and add 2 new tags
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const TIMELINE_SEARCHBOX = '[data-test-subj="timeline-super-select-search

export const EUI_FILTER_SELECT_ITEM = '.euiFilterSelectItem';

export const EUI_SELECTABLE_LIST_ITEM = '[data-test-subj="euiSelectableList"] li';

export const EUI_CHECKBOX = '.euiCheckbox__input';

export const COMBO_BOX_INPUT = '[data-test-subj="comboBoxInput"]';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* 2.0.
*/

import { TIMELINE_SEARCHBOX, EUI_FILTER_SELECT_ITEM } from '../screens/common/controls';
import { TIMELINE_SEARCHBOX, EUI_SELECTABLE_LIST_ITEM } from '../screens/common/controls';

import {
BULK_ACTIONS_BTN,
Expand Down Expand Up @@ -226,7 +226,7 @@ export const selectTimelineTemplate = (timelineTitle: string) => {
export const checkTagsInTagsFilter = (tags: string[]) => {
cy.get(RULES_TAGS_FILTER_BTN).contains(`Tags${tags.length}`).click();

cy.get(EUI_FILTER_SELECT_ITEM)
cy.get(EUI_SELECTABLE_LIST_ITEM)
.should('have.length', tags.length)
.each(($el, index) => {
cy.wrap($el).should('have.text', tags[index]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,45 +5,21 @@
* 2.0.
*/

import type { ChangeEvent } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiFilterButton,
EuiFilterSelectItem,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiPopover,
EuiText,
EuiFieldSearch,
EuiPopoverTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import React, { useEffect, useMemo, useState } from 'react';
import type { EuiSelectableOption } from '@elastic/eui';
import { EuiFilterButton, EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import { toggleSelectedGroup } from '../../../../../common/components/ml_popover/jobs_table/filters/toggle_selected_group';
import { caseInsensitiveSort } from '../helpers';

const TAGS_POPOVER_WIDTH = 274;

interface TagsFilterPopoverProps {
selectedTags: string[];
tags: string[];
onSelectedTagsChanged: (newTags: string[]) => void;
}

const PopoverContentWrapper = styled.div`
width: 275px;
`;

const ScrollableDiv = styled.div`
max-height: 250px;
overflow-y: auto;
`;

const TagOverflowContainer = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;

/**
* Popover for selecting tags to filter on
*
Expand All @@ -60,75 +36,78 @@ const TagsFilterPopoverComponent = ({
[selectedTags, tags]
);
const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [filterTags, setFilterTags] = useState(sortedTags);

const tagsComponent = useMemo(() => {
return filterTags.map((tag, index) => (
<EuiFilterSelectItem
checked={selectedTags.includes(tag) ? 'on' : undefined}
key={`${index}-${tag}`}
onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)}
title={tag}
>
<TagOverflowContainer>{tag}</TagOverflowContainer>
</EuiFilterSelectItem>
));
}, [onSelectedTagsChanged, selectedTags, filterTags]);
const [selectableOptions, setSelectableOptions] = useState<EuiSelectableOption[]>(() => {
const selectedTagsSet = new Set(selectedTags);

const onSearchInputChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setSearchInput(event.target.value);
}, []);
return sortedTags.map((label) => ({
label,
checked: selectedTagsSet.has(label) ? 'on' : undefined,
}));
});
const handleSelectableOptionsChange = (
newOptions: EuiSelectableOption[],
_: unknown,
changedOption: EuiSelectableOption
) => {
setSelectableOptions(newOptions);
toggleSelectedGroup(changedOption.label, selectedTags, onSelectedTagsChanged);
};

useEffect(() => {
setFilterTags(
sortedTags.filter((tag) => tag.toLowerCase().includes(searchInput.toLowerCase()))
);
}, [sortedTags, searchInput]);
const selectedTagsSet = new Set(selectedTags);
const newSelectableOptions: EuiSelectableOption[] = sortedTags.map((label) => ({
label,
checked: selectedTagsSet.has(label) ? 'on' : undefined,
}));

setSelectableOptions(newSelectableOptions);
}, [sortedTags, selectedTags]);

const triggerButton = (
<EuiFilterButton
grow
iconType="arrowDown"
onClick={() => setIsTagPopoverOpen(!isTagPopoverOpen)}
numFilters={tags.length}
isSelected={isTagPopoverOpen}
hasActiveFilters={selectedTags.length > 0}
numActiveFilters={selectedTags.length}
data-test-subj="tags-filter-popover-button"
>
{i18n.TAGS}
</EuiFilterButton>
);

return (
<EuiPopover
ownFocus
button={
<EuiFilterButton
grow={true}
data-test-subj={'tags-filter-popover-button'}
iconType="arrowDown"
onClick={() => setIsTagPopoverOpen(!isTagPopoverOpen)}
numFilters={tags.length}
isSelected={isTagPopoverOpen}
hasActiveFilters={selectedTags.length > 0}
numActiveFilters={selectedTags.length}
>
{i18n.TAGS}
</EuiFilterButton>
}
button={triggerButton}
isOpen={isTagPopoverOpen}
closePopover={() => setIsTagPopoverOpen(!isTagPopoverOpen)}
panelPaddingSize="none"
repositionOnScroll
panelProps={{
'data-test-subj': 'tags-filter-popover',
}}
>
<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>
<EuiSelectable
searchable
searchProps={{
placeholder: i18n.SEARCH_TAGS,
}}
aria-label={i18n.RULES_TAG_SEARCH}
options={selectableOptions}
onChange={handleSelectableOptionsChange}
emptyMessage={i18n.NO_TAGS_AVAILABLE}
noMatchesMessage={i18n.NO_TAGS_AVAILABLE}
>
{(list, search) => (
<div style={{ width: TAGS_POPOVER_WIDTH }}>
<EuiPopoverTitle>{search}</EuiPopoverTitle>
{list}
</div>
)}
</PopoverContentWrapper>
</EuiSelectable>
</EuiPopover>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,20 @@ export const TAGS = i18n.translate(
}
);

export const SEARCH_TAGS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.filters.searchTagsPlaceholder',
{
defaultMessage: 'Search tags',
}
);

export const RULES_TAG_SEARCH = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.filters.rulesTagSearchText',
{
defaultMessage: 'Rules tag search',
}
);

export const NO_TAGS_AVAILABLE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.filters.noTagsAvailableDescription',
{
Expand Down

0 comments on commit a873621

Please sign in to comment.