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)}