Skip to content

Commit

Permalink
[Cases] UI validation for assignees, tags and categories filters (ela…
Browse files Browse the repository at this point in the history
…stic#162411)

## Summary

Connected to elastic#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)
  • Loading branch information
js-jankisalvi authored and Devon Thomson committed Aug 1, 2023
1 parent 1bbe584 commit f1356b4
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) ?? [];

Expand Down Expand Up @@ -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,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -227,13 +228,17 @@ 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}
onSelectedOptionsChanged={handleSelectedCategories}
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]]);
});
});
});
21 changes: 21 additions & 0 deletions x-pack/plugins/cases/public/components/filter_popover/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@

import React, { useCallback, useState } from 'react';
import {
EuiCallOut,
EuiFilterButton,
EuiFilterSelectItem,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPanel,
EuiPopover,
EuiText,
Expand All @@ -22,6 +24,8 @@ interface FilterPopoverProps {
onSelectedOptionsChanged: (value: string[]) => void;
options: string[];
optionsEmptyLabel?: string;
limit?: number;
limitReachedMessage?: string;
selectedOptions: string[];
}

Expand Down Expand Up @@ -56,6 +60,8 @@ export const FilterPopoverComponent = ({
options,
optionsEmptyLabel,
selectedOptions,
limit,
limitReachedMessage,
}: FilterPopoverProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

Expand Down Expand Up @@ -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)}
Expand Down

0 comments on commit f1356b4

Please sign in to comment.