From f1356b4ae6dff49d30b0977ac39250f5ebfd4840 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 26 Jul 2023 09:31:49 +0200 Subject: [PATCH] [Cases] UI validation for assignees, tags and categories filters (#162411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Connected to https://github.com/elastic/kibana/issues/146945 This PR adds UI validations for `assignees`, `tags` and `categories` filter on cases list table and cases selector modal: Description | Limit | Done? | Documented? | UI? -- | -- | -- | -- | -- Maximum number of assignees to filter | 100 | ✅ | Yes | :white_check_mark: Maximum number of tags to filter | 100 | ✅ | Yes | :white_check_mark: Maximum number of categories to filter | 100 | ✅ | Yes | :white_check_mark: **Selector modal:** ![image](https://github.com/elastic/kibana/assets/117571355/69945b0a-57af-42c0-85e0-7df497d8796b) **Case list table:** ![image](https://github.com/elastic/kibana/assets/117571355/05c882f8-c160-40c3-aa9c-70ad4801e837) ![image](https://github.com/elastic/kibana/assets/117571355/e8e3eef8-81cf-46a2-8c8c-ee0d1f65a8ec) ![image](https://github.com/elastic/kibana/assets/117571355/a30bd780-d36f-437f-bf29-6eafed6accca) _Note:_ _screenshots are taken with 5 as maximum limit for `assignees`, `tags` and `categories` filter:_ ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [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/)) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../all_cases/assignees_filter.test.tsx | 31 ++++++- .../components/all_cases/assignees_filter.tsx | 8 ++ .../all_cases/table_filters.test.tsx | 93 +++++++++++++++++++ .../components/all_cases/table_filters.tsx | 5 + .../components/all_cases/translations.ts | 6 ++ .../components/filter_popover/index.test.tsx | 91 +++++++++++++++++- .../components/filter_popover/index.tsx | 21 +++++ 7 files changed, 253 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx index b42abd6271832..258f30b64eca8 100644 --- a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.test.tsx @@ -8,12 +8,14 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; import { screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; + import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import type { AssigneesFilterPopoverProps } from './assignees_filter'; import { AssigneesFilterPopover } from './assignees_filter'; import { userProfiles } from '../../containers/user_profiles/api.mock'; -import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import { MAX_ASSIGNEES_FILTER_LENGTH } from '../../../common/constants'; jest.mock('../../containers/user_profiles/api'); @@ -309,4 +311,31 @@ describe('AssigneesFilterPopover', () => { fireEvent.change(screen.getByPlaceholderText('Search users'), { target: { value: 'dingo' } }); expect(screen.queryByText('No assignees')).not.toBeInTheDocument(); }); + + it('shows warning message when reaching maximum limit to filter', async () => { + const maxAssignees = Array(MAX_ASSIGNEES_FILTER_LENGTH).fill(userProfiles[0]); + const props = { + ...defaultProps, + selectedAssignees: maxAssignees, + }; + appMockRender.render(); + + await waitFor(async () => { + userEvent.click(screen.getByTestId('options-filter-popover-button-assignees')); + expect( + screen.getByText(`${MAX_ASSIGNEES_FILTER_LENGTH} filters selected`) + ).toBeInTheDocument(); + }); + + await waitForEuiPopoverOpen(); + + expect( + screen.getByText( + `You've selected the maximum number of ${MAX_ASSIGNEES_FILTER_LENGTH} assignees` + ) + ).toBeInTheDocument(); + + expect(screen.getByTitle('No assignees')).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByTitle('No assignees')).toHaveAttribute('aria-disabled', 'true'); + }); }); diff --git a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx index d52563db79b84..8e88eec447e6c 100644 --- a/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/assignees_filter.tsx @@ -19,6 +19,7 @@ import { NoMatches } from '../user_profiles/no_matches'; import { bringCurrentUserToFrontAndSort, orderAssigneesIncludingNone } from '../user_profiles/sort'; import type { AssigneesFilteringSelection } from '../user_profiles/types'; import * as i18n from './translations'; +import { MAX_ASSIGNEES_FILTER_LENGTH } from '../../../common/constants'; export const NO_ASSIGNEES_VALUE = null; @@ -72,6 +73,11 @@ const AssigneesFilterPopoverComponent: React.FC = ( onDebounce, }); + const limitReachedMessage = useCallback( + (limit: number) => i18n.MAX_SELECTED_FILTER(limit, 'assignees'), + [] + ); + const searchResultProfiles = useMemo(() => { const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? []; @@ -117,6 +123,8 @@ const AssigneesFilterPopoverComponent: React.FC = ( clearButtonLabel: i18n.CLEAR_FILTERS, emptyMessage: , noMatchesMessage: !isUserTyping && !isLoadingData ? : , + limit: MAX_ASSIGNEES_FILTER_LENGTH, + limitReachedMessage, singleSelection: false, nullOptionLabel: i18n.NO_ASSIGNEES, }} diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx index c08febcaaff91..932d464b0d9a1 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.test.tsx @@ -17,6 +17,8 @@ import { OWNER_INFO, SECURITY_SOLUTION_OWNER, OBSERVABILITY_OWNER, + MAX_TAGS_FILTER_LENGTH, + MAX_CATEGORY_FILTER_LENGTH, } from '../../../common/constants'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; @@ -153,6 +155,97 @@ describe('CasesTableFilters ', () => { expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] }); }); + it('should show warning message when maximum tags selected', async () => { + const newTags = Array(MAX_TAGS_FILTER_LENGTH).fill('coke'); + (useGetTags as jest.Mock).mockReturnValue({ data: newTags, isLoading: false }); + + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + tags: newTags, + }, + }; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); + }); + + it('should show warning message when tags selection reaches maximum limit', async () => { + const newTags = Array(MAX_TAGS_FILTER_LENGTH - 1).fill('coke'); + const tags = [...newTags, 'pepsi']; + (useGetTags as jest.Mock).mockReturnValue({ data: tags, isLoading: false }); + + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + tags: newTags, + }, + }; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(screen.getByTestId(`options-filter-popover-item-${tags[tags.length - 1]}`)); + + expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); + }); + + it('should not show warning message when one of the tags deselected after reaching the limit', async () => { + const newTags = Array(MAX_TAGS_FILTER_LENGTH).fill('coke'); + (useGetTags as jest.Mock).mockReturnValue({ data: newTags, isLoading: false }); + + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + tags: newTags, + }, + }; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); + + userEvent.click(screen.getAllByTestId(`options-filter-popover-item-${newTags[0]}`)[0]); + + expect(screen.queryByTestId('maximum-length-warning')).not.toBeInTheDocument(); + }); + + it('should show warning message when maximum categories selected', async () => { + const newCategories = Array(MAX_CATEGORY_FILTER_LENGTH).fill('snickers'); + (useGetCategories as jest.Mock).mockReturnValue({ data: newCategories, isLoading: false }); + + const ourProps = { + ...props, + initial: { + ...DEFAULT_FILTER_OPTIONS, + category: newCategories, + }, + }; + + appMockRender.render(); + + userEvent.click(screen.getByTestId('options-filter-popover-button-Categories')); + + await waitForEuiPopoverOpen(); + + expect(screen.getByTestId('maximum-length-warning')).toBeInTheDocument(); + }); + it('should remove assignee from selected assignees when assignee no longer exists', async () => { const overrideProps = { ...props, diff --git a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx index cfe0eed8f777a..17aea3a947899 100644 --- a/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/table_filters.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup, EuiButton } from '@elastic/eui'; import type { CaseStatusWithAllStatus, CaseSeverityWithAll } from '../../../common/ui/types'; +import { MAX_TAGS_FILTER_LENGTH, MAX_CATEGORY_FILTER_LENGTH } from '../../../common/constants'; import { StatusAll } from '../../../common/ui/types'; import { CaseStatuses } from '../../../common/api'; import type { FilterOptions } from '../../containers/types'; @@ -227,6 +228,8 @@ const CasesTableFiltersComponent = ({ selectedOptions={selectedTags} options={tags} optionsEmptyLabel={i18n.NO_TAGS_AVAILABLE} + limit={MAX_TAGS_FILTER_LENGTH} + limitReachedMessage={i18n.MAX_SELECTED_FILTER(MAX_TAGS_FILTER_LENGTH, 'tags')} /> {availableSolutions.length > 1 && ( + i18n.translate('xpack.cases.userProfile.maxSelectedAssigneesFilter', { + defaultMessage: "You've selected the maximum number of {count} {field}", + values: { count, field }, + }); + export const SHOW_LESS = i18n.translate('xpack.cases.allCasesView.showLessAvatars', { defaultMessage: 'show less', }); diff --git a/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx index a6c6de8f19770..23d53c6d83d9b 100644 --- a/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx +++ b/x-pack/plugins/cases/public/components/filter_popover/index.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; +import userEvent from '@testing-library/user-event'; import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { FilterPopover } from '.'; -import userEvent from '@testing-library/user-event'; describe('FilterPopover ', () => { let appMockRender: AppMockRenderer; @@ -110,4 +110,93 @@ describe('FilterPopover ', () => { expect(onSelectedOptionsChanged).toHaveBeenCalledWith([]); }); + + describe('maximum limit', () => { + const newTags = ['coke', 'pepsi', 'sprite', 'juice', 'water']; + const maxLength = 3; + const maxLengthLabel = `You have selected maximum number of ${maxLength} tags to filter`; + + it('should show message when maximum options are selected', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(getByTestId('maximum-length-warning')).toHaveTextContent(maxLengthLabel); + + expect(getByTestId(`options-filter-popover-item-${newTags[3]}`)).toHaveProperty('disabled'); + expect(getByTestId(`options-filter-popover-item-${newTags[4]}`)).toHaveProperty('disabled'); + }); + + it('should not show message when maximum length label is missing', async () => { + const { getByTestId, queryByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(queryByTestId('maximum-length-warning')).not.toBeInTheDocument(); + expect(getByTestId(`options-filter-popover-item-${newTags[3]}`)).toHaveProperty('disabled'); + expect(getByTestId(`options-filter-popover-item-${newTags[4]}`)).toHaveProperty('disabled'); + }); + + it('should not show message and disable options when maximum length property is missing', async () => { + const { getByTestId, queryByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + expect(queryByTestId('maximum-length-warning')).not.toBeInTheDocument(); + expect(getByTestId(`options-filter-popover-item-${newTags[4]}`)).toHaveProperty( + 'disabled', + false + ); + }); + + it('should allow to select more options when maximum length property is missing', async () => { + const { getByTestId } = appMockRender.render( + + ); + + userEvent.click(getByTestId('options-filter-popover-button-Tags')); + + await waitForEuiPopoverOpen(); + + userEvent.click(getByTestId(`options-filter-popover-item-${newTags[1]}`)); + + expect(onSelectedOptionsChanged).toHaveBeenCalledWith([newTags[0], newTags[2], newTags[1]]); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/filter_popover/index.tsx b/x-pack/plugins/cases/public/components/filter_popover/index.tsx index 0ef1d5a887c21..fea664c021d54 100644 --- a/x-pack/plugins/cases/public/components/filter_popover/index.tsx +++ b/x-pack/plugins/cases/public/components/filter_popover/index.tsx @@ -7,10 +7,12 @@ import React, { useCallback, useState } from 'react'; import { + EuiCallOut, EuiFilterButton, EuiFilterSelectItem, EuiFlexGroup, EuiFlexItem, + EuiHorizontalRule, EuiPanel, EuiPopover, EuiText, @@ -22,6 +24,8 @@ interface FilterPopoverProps { onSelectedOptionsChanged: (value: string[]) => void; options: string[]; optionsEmptyLabel?: string; + limit?: number; + limitReachedMessage?: string; selectedOptions: string[]; } @@ -56,6 +60,8 @@ export const FilterPopoverComponent = ({ options, optionsEmptyLabel, selectedOptions, + limit, + limitReachedMessage, }: FilterPopoverProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -87,10 +93,25 @@ export const FilterPopoverComponent = ({ panelPaddingSize="none" repositionOnScroll > + {limit && limitReachedMessage && selectedOptions.length >= limit ? ( + <> + + + + + ) : null} {options.map((option, index) => ( = limit && !selectedOptions.includes(option) + )} data-test-subj={`options-filter-popover-item-${option}`} key={`${index}-${option}`} onClick={toggleSelectedGroupCb.bind(null, option)}