[Cases] UI validation for assignees, tags and categories filters (#162411)

## 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 |

Maximum number of tags to filter | 100 |  | Yes | 
Maximum number of categories to filter | 100 |  | Yes |


**Selector modal:** 


![image](69945b0a-57af-42c0-85e0-7df497d8796b)

**Case list table:** 


![image](05c882f8-c160-40c3-aa9c-70ad4801e837)


![image](e8e3eef8-81cf-46a2-8c8c-ee0d1f65a8ec)


![image](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)
This commit is contained in:
Janki Salvi 2023-07-26 09:31:49 +02:00 committed by GitHub
parent 9538fab090
commit 22dc78273c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 253 additions and 2 deletions

View file

@ -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(<AssigneesFilterPopover {...props} />);
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');
});
});

View file

@ -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<AssigneesFilterPopoverProps> = (
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<AssigneesFilterPopoverProps> = (
clearButtonLabel: i18n.CLEAR_FILTERS,
emptyMessage: <EmptyMessage />,
noMatchesMessage: !isUserTyping && !isLoadingData ? <NoMatches /> : <EmptyMessage />,
limit: MAX_ASSIGNEES_FILTER_LENGTH,
limitReachedMessage,
singleSelection: false,
nullOptionLabel: i18n.NO_ASSIGNEES,
}}

View file

@ -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(<CasesTableFilters {...ourProps} />);
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(<CasesTableFilters {...ourProps} />);
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(<CasesTableFilters {...ourProps} />);
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(<CasesTableFilters {...ourProps} />);
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,

View file

@ -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')}
/>
<FilterPopover
buttonLabel={i18n.CATEGORIES}
@ -234,6 +237,8 @@ const CasesTableFiltersComponent = ({
selectedOptions={selectedCategories}
options={categories}
optionsEmptyLabel={i18n.NO_CATEGORIES_AVAILABLE}
limit={MAX_CATEGORY_FILTER_LENGTH}
limitReachedMessage={i18n.MAX_SELECTED_FILTER(MAX_CATEGORY_FILTER_LENGTH, 'categories')}
/>
{availableSolutions.length > 1 && (
<SolutionFilter

View file

@ -138,6 +138,12 @@ export const NO_ASSIGNEES = i18n.translate(
}
);
export const MAX_SELECTED_FILTER = (count: number, field: string) =>
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',
});

View file

@ -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(
<FilterPopover
buttonLabel={'Tags'}
onSelectedOptionsChanged={onSelectedOptionsChanged}
selectedOptions={[...newTags.slice(0, 3)]}
options={newTags}
limit={maxLength}
limitReachedMessage={maxLengthLabel}
/>
);
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(
<FilterPopover
buttonLabel={'Tags'}
onSelectedOptionsChanged={onSelectedOptionsChanged}
selectedOptions={[newTags[0], newTags[2]]}
options={newTags}
limit={maxLength}
/>
);
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(
<FilterPopover
buttonLabel={'Tags'}
onSelectedOptionsChanged={onSelectedOptionsChanged}
selectedOptions={[newTags[0], newTags[2]]}
options={newTags}
limitReachedMessage={maxLengthLabel}
/>
);
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(
<FilterPopover
buttonLabel={'Tags'}
onSelectedOptionsChanged={onSelectedOptionsChanged}
selectedOptions={[newTags[0], newTags[2]]}
options={newTags}
/>
);
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]]);
});
});
});

View file

@ -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 ? (
<>
<EuiHorizontalRule margin="none" />
<EuiCallOut
title={limitReachedMessage}
color="warning"
size="s"
data-test-subj="maximum-length-warning"
/>
<EuiHorizontalRule margin="none" />
</>
) : null}
<ScrollableDiv>
{options.map((option, index) => (
<EuiFilterSelectItem
checked={selectedOptions.includes(option) ? 'on' : undefined}
disabled={Boolean(
limit && selectedOptions.length >= limit && !selectedOptions.includes(option)
)}
data-test-subj={`options-filter-popover-item-${option}`}
key={`${index}-${option}`}
onClick={toggleSelectedGroupCb.bind(null, option)}