mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:**  **Case list table:**    _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:
parent
9538fab090
commit
22dc78273c
7 changed files with 253 additions and 2 deletions
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue