diff --git a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx
new file mode 100644
index 0000000000000..13d04b7b593a5
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_flyout.test.tsx
@@ -0,0 +1,93 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import type { AppMockRenderer } from '../../../common/mock';
+import { createAppMockRenderer } from '../../../common/mock';
+import { basicCase } from '../../../containers/mock';
+import { waitForComponentToUpdate } from '../../../common/test_utils';
+import { EditAssigneesFlyout } from './edit_assignees_flyout';
+import { waitFor } from '@testing-library/react';
+
+jest.mock('../../../containers/user_profiles/api');
+
+describe('EditAssigneesFlyout', () => {
+ let appMock: AppMockRenderer;
+
+ /**
+ * Case one has the following assignees: coke, pepsi, one
+ * Case two has the following assignees: one, three
+ * All available assignees are: one, two, three, coke, pepsi
+ */
+ const props = {
+ selectedCases: [basicCase],
+ onClose: jest.fn(),
+ onSaveAssignees: jest.fn(),
+ };
+
+ beforeEach(() => {
+ appMock = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', async () => {
+ const result = appMock.render();
+
+ expect(result.getByTestId('cases-edit-assignees-flyout')).toBeInTheDocument();
+ expect(result.getByTestId('cases-edit-assignees-flyout-title')).toBeInTheDocument();
+ expect(result.getByTestId('cases-edit-assignees-flyout-cancel')).toBeInTheDocument();
+ expect(result.getByTestId('cases-edit-assignees-flyout-submit')).toBeInTheDocument();
+
+ await waitForComponentToUpdate();
+ });
+
+ it('calls onClose when pressing the cancel button', async () => {
+ const result = appMock.render();
+
+ userEvent.click(result.getByTestId('cases-edit-assignees-flyout-cancel'));
+ expect(props.onClose).toHaveBeenCalled();
+
+ await waitForComponentToUpdate();
+ });
+
+ it('calls onSaveTags when pressing the save selection button', async () => {
+ const result = appMock.render();
+
+ await waitForComponentToUpdate();
+
+ await waitFor(() => {
+ expect(result.getByText('Damaged Raccoon')).toBeInTheDocument();
+ });
+
+ userEvent.click(result.getByText('Damaged Raccoon'));
+ userEvent.click(result.getByTestId('cases-edit-assignees-flyout-submit'));
+
+ expect(props.onSaveAssignees).toHaveBeenCalledWith({
+ selectedItems: [],
+ unSelectedItems: ['u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0'],
+ });
+ });
+
+ it('shows the case title when selecting one case', async () => {
+ const result = appMock.render();
+
+ expect(result.getByText(basicCase.title)).toBeInTheDocument();
+
+ await waitForComponentToUpdate();
+ });
+
+ it('shows the number of total selected cases in the title when selecting multiple cases', async () => {
+ const result = appMock.render(
+
+ );
+
+ expect(result.getByText('Selected cases: 2')).toBeInTheDocument();
+
+ await waitForComponentToUpdate();
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx
new file mode 100644
index 0000000000000..89dc5fa73bc71
--- /dev/null
+++ b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.test.tsx
@@ -0,0 +1,364 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import type { AppMockRenderer } from '../../../common/mock';
+import { createAppMockRenderer } from '../../../common/mock';
+import { EditAssigneesSelectable } from './edit_assignees_selectable';
+import { basicCase } from '../../../containers/mock';
+import { waitForComponentToUpdate } from '../../../common/test_utils';
+import userEvent from '@testing-library/user-event';
+import { userProfiles, userProfilesMap } from '../../../containers/user_profiles/api.mock';
+import { act, waitFor } from '@testing-library/react';
+
+jest.mock('../../../containers/user_profiles/api');
+
+describe('EditAssigneesSelectable', () => {
+ let appMock: AppMockRenderer;
+
+ /**
+ * Case has the following tags: Damaged Raccoon
+ * All available tags are: Damaged Raccoon, Physical Dinosaur, Wet Dingo
+ */
+ const props = {
+ selectedCases: [basicCase],
+ isLoading: false,
+ userProfiles: userProfilesMap,
+ onChangeAssignees: jest.fn(),
+ };
+
+ /**
+ * Case one has the following assignees: Damaged Raccoon
+ * Case two has the following assignees: Damaged Raccoon, Physical Dinosaur
+ * All available assignees are: Damaged Raccoon, Physical Dinosaur, Wet Dingo, Silly Hare, Convenient Orca
+ */
+ const propsMultipleCases = {
+ selectedCases: [
+ basicCase,
+ { ...basicCase, assignees: [...basicCase.assignees, { uid: userProfiles[1].uid }] },
+ ],
+ isLoading: false,
+ userProfiles: userProfilesMap,
+ onChangeAssignees: jest.fn(),
+ };
+
+ beforeAll(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.clearAllTimers();
+ });
+
+ afterAll(() => {
+ jest.useRealTimers();
+ });
+
+ beforeEach(() => {
+ appMock = createAppMockRenderer();
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', async () => {
+ const result = appMock.render();
+
+ expect(result.getByTestId('cases-actions-assignees-edit-selectable')).toBeInTheDocument();
+ expect(result.getByPlaceholderText('Search')).toBeInTheDocument();
+ expect(result.getByText('Selected: 1')).toBeInTheDocument();
+
+ for (const userProfile of userProfiles) {
+ // @ts-ignore: full name exists
+ expect(result.getByText(userProfile.user.full_name)).toBeInTheDocument();
+ }
+
+ await waitForComponentToUpdate();
+ });
+
+ it('renders the selected assignees label correctly', async () => {
+ const result = appMock.render();
+
+ expect(result.getByText('Selected: 2')).toBeInTheDocument();
+
+ for (const userProfile of userProfilesMap.values()) {
+ // @ts-ignore: full name exists
+ expect(result.getByText(userProfile.user.full_name)).toBeInTheDocument();
+ }
+
+ await waitForComponentToUpdate();
+ });
+
+ it('renders the assignees icons correctly', async () => {
+ const result = appMock.render();
+
+ for (const [uid, icon] of [
+ [userProfiles[0].uid, 'check'],
+ [userProfiles[1].uid, 'asterisk'],
+ [userProfiles[2].uid, 'empty'],
+ ]) {
+ const iconDataTestSubj = `cases-actions-assignees-edit-selectable-assignee-${uid}-icon-${icon}`;
+ expect(result.getByTestId(iconDataTestSubj)).toBeInTheDocument();
+ }
+
+ await waitForComponentToUpdate();
+ });
+
+ it('selects and unselects correctly assignees with one case', async () => {
+ const result = appMock.render();
+
+ for (const userProfile of userProfiles) {
+ // @ts-ignore: full name exists
+ userEvent.click(result.getByText(userProfile.user.full_name));
+ }
+
+ expect(props.onChangeAssignees).toBeCalledTimes(3);
+ expect(props.onChangeAssignees).nthCalledWith(3, {
+ selectedItems: [
+ 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
+ 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
+ ],
+ unSelectedItems: ['u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0'],
+ });
+
+ await waitForComponentToUpdate();
+ });
+
+ it('selects and unselects correctly assignees with multiple cases', async () => {
+ const result = appMock.render();
+
+ for (const userProfile of userProfiles) {
+ // @ts-ignore: full name exists
+ userEvent.click(result.getByText(userProfile.user.full_name));
+ }
+
+ expect(propsMultipleCases.onChangeAssignees).toBeCalledTimes(3);
+ expect(propsMultipleCases.onChangeAssignees).nthCalledWith(3, {
+ selectedItems: [
+ 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
+ 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
+ ],
+ unSelectedItems: ['u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0'],
+ });
+
+ await waitForComponentToUpdate();
+ });
+
+ it('renders the icons correctly after selecting and deselecting assignees', async () => {
+ const result = appMock.render();
+
+ for (const userProfile of userProfiles) {
+ // @ts-ignore: full name exists
+ userEvent.click(result.getByText(userProfile.user.full_name));
+ }
+
+ for (const [uid, icon] of [
+ [userProfiles[0].uid, 'empty'],
+ [userProfiles[1].uid, 'check'],
+ [userProfiles[2].uid, 'check'],
+ ]) {
+ const iconDataTestSubj = `cases-actions-assignees-edit-selectable-assignee-${uid}-icon-${icon}`;
+ expect(result.getByTestId(iconDataTestSubj)).toBeInTheDocument();
+ }
+
+ expect(propsMultipleCases.onChangeAssignees).toBeCalledTimes(3);
+ expect(propsMultipleCases.onChangeAssignees).nthCalledWith(3, {
+ selectedItems: [
+ 'u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0',
+ 'u_9xDEQqUqoYCnFnPPLq5mIRHKL8gBTo_NiKgOnd5gGk0_0',
+ ],
+ unSelectedItems: ['u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0'],
+ });
+
+ await waitForComponentToUpdate();
+ });
+
+ it('sort users alphabetically correctly', async () => {
+ const reversedUserProfiles = new Map(
+ [...userProfiles].reverse().map((profile) => [profile.uid, profile])
+ );
+
+ const result = appMock.render(
+
+ );
+ const allUsersInView = result.getAllByRole('option');
+
+ expect(allUsersInView.length).toBe(3);
+
+ expect(allUsersInView[0].textContent?.includes('Damaged Raccoon')).toBe(true);
+ expect(allUsersInView[1].textContent?.includes('Physical Dinosaur')).toBe(true);
+ expect(allUsersInView[2].textContent?.includes('Wet Dingo')).toBe(true);
+
+ await waitForComponentToUpdate();
+ });
+
+ it('search and sorts alphabetically', async () => {
+ // Silly Hare
+ const searchedUserDataTestSubj =
+ 'cases-actions-assignees-edit-selectable-assignee-u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0';
+
+ const result = appMock.render();
+
+ userEvent.type(result.getByPlaceholderText('Search'), 's');
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(result.getByTestId(searchedUserDataTestSubj));
+ });
+
+ const searchResults = result.getAllByRole('option');
+
+ expect(searchResults.length).toBe(2);
+ expect(searchResults[0].textContent?.includes('Physical Dinosaur')).toBe(true);
+ expect(searchResults[1].textContent?.includes('Silly Hare')).toBe(true);
+
+ await waitForComponentToUpdate();
+ });
+
+ it('selecting and deselecting a searched user does not show it after the user cleared the search', async () => {
+ // Silly Hare
+ const searchedUserDataTestSubj =
+ 'cases-actions-assignees-edit-selectable-assignee-u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0';
+
+ const result = appMock.render();
+
+ userEvent.type(result.getByPlaceholderText('Search'), 's');
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(result.getByTestId(searchedUserDataTestSubj));
+ });
+
+ // selects
+ userEvent.click(result.getByTestId(searchedUserDataTestSubj));
+ // deselect
+ userEvent.click(result.getByTestId(searchedUserDataTestSubj));
+ // clear search results
+ userEvent.click(result.getByTestId('clearSearchButton'));
+
+ await waitFor(() => {
+ expect(result.getByText('Damaged Raccoon'));
+ });
+
+ expect(result.queryByTestId(searchedUserDataTestSubj)).not.toBeInTheDocument();
+
+ await waitForComponentToUpdate();
+ });
+
+ it('does not show the same user in search results if it is already in the initial user profile mapping', async () => {
+ const result = appMock.render();
+
+ userEvent.type(result.getByPlaceholderText('Search'), 's');
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ const searchResults = result.getAllByTestId(
+ // Physical Dinosaur
+ 'cases-actions-assignees-edit-selectable-assignee-u_A_tM4n0wPkdiQ9smmd8o0Hr_h61XQfu8aRPh9GMoRoc_0-icon-empty'
+ );
+
+ expect(searchResults.length).toBe(1);
+
+ await waitForComponentToUpdate();
+ });
+
+ it('selects a searched user correctly', async () => {
+ // Silly Hare
+ const searchedUserDataTestSubj =
+ 'cases-actions-assignees-edit-selectable-assignee-u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0';
+
+ const result = appMock.render();
+
+ userEvent.type(result.getByPlaceholderText('Search'), 's');
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(result.getByTestId(searchedUserDataTestSubj));
+ });
+
+ userEvent.click(result.getByTestId(searchedUserDataTestSubj));
+ expect(props.onChangeAssignees).toBeCalledWith({
+ selectedItems: [
+ 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0',
+ 'u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0',
+ ],
+ unSelectedItems: [],
+ });
+
+ await waitForComponentToUpdate();
+ });
+
+ it('shows deselected users from the initial user profile mapping', async () => {
+ const result = appMock.render();
+
+ // @ts-ignore: full name exists
+ userEvent.click(result.getByText(userProfiles[0].user.full_name));
+
+ // @ts-ignore: full name exists
+ expect(result.getByText(userProfiles[0].user.full_name)).toBeInTheDocument();
+ // ensures that the icon is set to empty
+ expect(
+ result.getByTestId(
+ 'cases-actions-assignees-edit-selectable-assignee-u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0-icon-empty'
+ )
+ ).toBeInTheDocument();
+
+ expect(props.onChangeAssignees).toBeCalledWith({
+ selectedItems: [],
+ unSelectedItems: ['u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0'],
+ });
+
+ await waitForComponentToUpdate();
+ });
+
+ it('does not shows initial empty search results on the list of users', async () => {
+ // Silly Hare
+ const searchedUserDataTestSubj =
+ 'cases-actions-assignees-edit-selectable-assignee-u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0';
+
+ const result = appMock.render();
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(result.getByText('Damaged Raccoon'));
+ });
+
+ expect(result.queryByTestId(searchedUserDataTestSubj)).not.toBeInTheDocument();
+
+ await waitForComponentToUpdate();
+ });
+
+ it('shows the no matching component', async () => {
+ const result = appMock.render();
+
+ userEvent.type(result.getByPlaceholderText('Search'), 'not-exists');
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ await waitFor(() => {
+ expect(
+ result.getAllByTestId('case-user-profiles-assignees-popover-no-matches')[0]
+ ).toBeInTheDocument();
+ });
+
+ await waitForComponentToUpdate();
+ });
+});
diff --git a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.tsx b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.tsx
index 296ae0be2652b..b68f279570ad6 100644
--- a/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.tsx
+++ b/x-pack/plugins/cases/public/components/actions/assignees/edit_assignees_selectable.tsx
@@ -15,6 +15,9 @@ import {
EuiTextColor,
EuiHighlight,
EuiIcon,
+ EuiSpacer,
+ EuiText,
+ useEuiTheme,
} from '@elastic/eui';
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
@@ -26,6 +29,8 @@ import * as i18n from './translations';
import { useItemsState } from '../use_items_state';
import type { ItemSelectableOption, ItemsSelectionState } from '../types';
import { useCasesContext } from '../../cases_context/use_cases_context';
+import { EmptyMessage } from '../../user_profiles/empty_message';
+import { NoMatches } from '../../user_profiles/no_matches';
interface Props {
selectedCases: Case[];
@@ -43,6 +48,7 @@ const EditAssigneesSelectableComponent: React.FC = ({
onChangeAssignees,
}) => {
const { owner: owners } = useCasesContext();
+ const { euiTheme } = useEuiTheme();
const { isUserTyping, onContentChange, onDebounce } = useIsUserTyping();
// TODO: Include unknown users
const userProfileIds = [...userProfiles.keys()];
@@ -78,12 +84,13 @@ const EditAssigneesSelectableComponent: React.FC = ({
return {
key: item.key,
label: item.key,
+ 'data-test-subj': `cases-actions-assignees-edit-selectable-assignee-${item.key}`,
} as AssigneeSelectableOption;
},
[searchResultUserProfiles, userProfiles]
);
- const { options, onChange } = useItemsState({
+ const { options, totalSelectedItems, onChange } = useItemsState({
items: userProfileIds,
selectedCases,
fieldSelector: (theCase) => theCase.assignees.map(({ uid }) => uid),
@@ -95,13 +102,15 @@ const EditAssigneesSelectableComponent: React.FC = ({
searchResultUserProfiles: searchResultUserProfiles ?? [],
options,
searchValue,
+ initialUserProfiles: userProfiles,
});
const isLoadingData = isLoading || isLoadingSuggest || isUserTyping;
const renderOption = useCallback(
(option: AssigneeSelectableOption, search: string) => {
- const dataTestSubj = `cases-actions-tags-edit-selectable-tag-${option.label}-icon-${option.itemIcon}`;
+ const icon = option.itemIcon ?? 'empty';
+ const dataTestSubj = `cases-actions-assignees-edit-selectable-assignee-${option.key}-icon-${icon}`;
if (!option.user) {
return {option.label};
@@ -117,7 +126,7 @@ const EditAssigneesSelectableComponent: React.FC = ({
responsive={false}
>
-
+
@@ -169,20 +178,40 @@ const EditAssigneesSelectableComponent: React.FC = ({
isClearable: !isLoadingData,
onChange: onSearchChange,
value: searchValue,
- 'data-test-subj': 'cases-actions-tags-edit-selectable-search-input',
+ 'data-test-subj': 'cases-actions-assignees-edit-selectable-search-input',
}}
renderOption={renderOption}
listProps={{ showIcons: false }}
onChange={onChange}
- noMatchesMessage={'no match'}
- emptyMessage={'empty assignees'}
- data-test-subj="cases-actions-tags-edit-selectable"
+ noMatchesMessage={!isLoadingData ? : }
+ emptyMessage={}
+ data-test-subj="cases-actions-assignees-edit-selectable"
height="full"
>
{(list, search) => {
return (
<>
{search}
+
+
+
+
+ {i18n.SELECTED_ASSIGNEES(totalSelectedItems)}
+
+
+
{list}
>
@@ -200,34 +229,36 @@ const getDisplayOptions = ({
searchResultUserProfiles,
options,
searchValue,
+ initialUserProfiles,
}: {
searchResultUserProfiles: UserProfileWithAvatar[];
options: AssigneeSelectableOption[];
searchValue: string;
+ initialUserProfiles: Map;
}) => {
- const searchResultsOptions =
- searchResultUserProfiles
- ?.filter((profile) => !options.find((option) => isMatchingOption(option, profile)))
- ?.map((profile) => toSelectableOption(profile)) ?? [];
-
- const [selectedOrPartialOptions, unselectedOptions] = options.reduce(
- (acc, option) => {
- // TODO fix type
- if (option?.data?.itemIcon !== 'empty') {
- acc[0].push(option);
- } else {
- acc[1].push(option);
- }
-
- return acc;
- },
- [[], []] as [AssigneeSelectableOption[], AssigneeSelectableOption[]]
- );
+ /**
+ * If the user does not perform any search we do not want to show
+ * the results of an empty search to the initial list of users.
+ * We also filter out users that appears both in the initial list
+ * and the search results
+ */
+ const searchResultsOptions = isEmpty(searchValue)
+ ? []
+ : searchResultUserProfiles
+ ?.filter((profile) => !options.find((option) => isMatchingOption(option, profile)))
+ ?.map((profile) => toSelectableOption(profile)) ?? [];
+ /**
+ * In the initial view, when the user does not perform any search,
+ * we want to filter out options that are not in the initial user profile
+ * mapping or profiles returned by the search result that are not selected
+ */
+ const filteredOptions = isEmpty(searchValue)
+ ? options.filter(
+ (option) => initialUserProfiles.has(option?.data?.uid) || option?.data?.itemIcon !== 'empty'
+ )
+ : [...options];
- const finalOptions = [
- ...sortOptionsAlphabetically(selectedOrPartialOptions),
- ...sortOptionsAlphabetically([...unselectedOptions, ...searchResultsOptions]),
- ];
+ const finalOptions = sortOptionsAlphabetically([...searchResultsOptions, ...filteredOptions]);
return finalOptions;
};
@@ -244,6 +275,7 @@ const toSelectableOption = (userProfile: UserProfileWithAvatar): AssigneeSelecta
key: userProfile.uid,
label: getUserDisplayName(userProfile.user),
data: userProfile,
+ 'data-test-subj': `cases-actions-assignees-edit-selectable-assignee-${userProfile.uid}`,
} as unknown as AssigneeSelectableOption;
};
diff --git a/x-pack/plugins/cases/public/components/actions/assignees/translations.ts b/x-pack/plugins/cases/public/components/actions/assignees/translations.ts
index ae7fc2ccbd7c1..f2d63ce2e5895 100644
--- a/x-pack/plugins/cases/public/components/actions/assignees/translations.ts
+++ b/x-pack/plugins/cases/public/components/actions/assignees/translations.ts
@@ -12,3 +12,9 @@ export { EDITED_CASES, SELECTED_CASES, SAVE_SELECTION, SEARCH_PLACEHOLDER } from
export const EDIT_ASSIGNEES = i18n.translate('xpack.cases.actions.assignees.edit', {
defaultMessage: 'Edit assignees',
});
+
+export const SELECTED_ASSIGNEES = (selectedAssignees: number) =>
+ i18n.translate('xpack.cases.actions.assignees.selectedAssignees', {
+ defaultMessage: 'Selected: {selectedAssignees}',
+ values: { selectedAssignees },
+ });
diff --git a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx
index 26277686602bb..b6ebaa03be06c 100644
--- a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx
+++ b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_flyout.test.tsx
@@ -12,7 +12,7 @@ import { createAppMockRenderer } from '../../../common/mock';
import { basicCase } from '../../../containers/mock';
import { waitForComponentToUpdate } from '../../../common/test_utils';
import { EditTagsFlyout } from './edit_tags_flyout';
-import { waitFor } from '@testing-library/dom';
+import { waitFor } from '@testing-library/react';
jest.mock('../../../containers/api');
@@ -65,7 +65,6 @@ describe('EditTagsFlyout', () => {
});
userEvent.click(result.getByText('coke'));
-
userEvent.click(result.getByTestId('cases-edit-tags-flyout-submit'));
expect(props.onSaveTags).toHaveBeenCalledWith({
diff --git a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx
index c1671da72bc3e..3bdbb60cac8e8 100644
--- a/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx
+++ b/x-pack/plugins/cases/public/components/actions/tags/edit_tags_selectable.tsx
@@ -47,6 +47,7 @@ const itemToSelectableOption = (item: {
return {
key: item.key,
label: item.key,
+ 'data-test-subj': `cases-actions-tags-edit-selectable-tag-${item.key}`,
} as ItemSelectableOption;
};
@@ -65,6 +66,7 @@ const EditTagsSelectableComponent: React.FC = ({
onChangeItems: onChangeTags,
}
);
+
const [searchValue, setSearchValue] = useState('');
const { euiTheme } = useEuiTheme();
diff --git a/x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx b/x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx
index e3a14ff49072b..a680ec655652a 100644
--- a/x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx
+++ b/x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx
@@ -585,7 +585,7 @@ describe('useItemsState', () => {
});
it('calls fieldSelector correctly', async () => {
- const { result } = renderHook(() => useItemsState(props), {
+ renderHook(() => useItemsState(props), {
wrapper: appMockRender.AppWrapper,
});
diff --git a/x-pack/plugins/cases/public/components/actions/use_items_state.tsx b/x-pack/plugins/cases/public/components/actions/use_items_state.tsx
index 05d8eca72f920..6e0777941ecaa 100644
--- a/x-pack/plugins/cases/public/components/actions/use_items_state.tsx
+++ b/x-pack/plugins/cases/public/components/actions/use_items_state.tsx
@@ -244,7 +244,7 @@ export const useItemsState = ({
const [state, dispatch] = useReducer(
itemsReducer,
{ items, selectedCases, fieldSelector },
- (args) => getInitialItemsState(args)
+ getInitialItemsState
);
const stateToOptions = useCallback((): ItemSelectableOption[] => {
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
index 6ab3cbe898547..4d2aa71e231c6 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
@@ -22,6 +22,7 @@ import {
} from '../../common/mock';
jest.mock('../../containers/api');
+jest.mock('../../containers/user_profiles/api');
describe('useActions', () => {
let appMockRender: AppMockRenderer;
@@ -230,6 +231,96 @@ describe('useActions', () => {
});
});
+ describe('Flyouts', () => {
+ it('change the tags of the case', async () => {
+ const updateCasesSpy = jest.spyOn(api, 'updateCases');
+
+ const { result } = renderHook(() => useActions({ disableActions: false }), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ const comp = result.current.actions!.render(basicCase) as React.ReactElement;
+ const res = appMockRender.render(comp);
+
+ act(() => {
+ userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`));
+ });
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-bulk-action-tags')).toBeInTheDocument();
+ });
+
+ act(() => {
+ userEvent.click(res.getByTestId('cases-bulk-action-tags'), undefined, {
+ skipPointerEventsCheck: true,
+ });
+ });
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-edit-tags-flyout')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(res.getByText('coke')).toBeInTheDocument();
+ });
+
+ userEvent.click(res.getByText('coke'));
+ userEvent.click(res.getByTestId('cases-edit-tags-flyout-submit'));
+
+ await waitFor(() => {
+ expect(updateCasesSpy).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(res.queryByTestId('cases-edit-tags-flyout')).toBeFalsy();
+ });
+ });
+
+ it('change the assignees of the case', async () => {
+ const updateCasesSpy = jest.spyOn(api, 'updateCases');
+
+ const { result } = renderHook(() => useActions({ disableActions: false }), {
+ wrapper: appMockRender.AppWrapper,
+ });
+
+ const comp = result.current.actions!.render(basicCase) as React.ReactElement;
+ const res = appMockRender.render(comp);
+
+ act(() => {
+ userEvent.click(res.getByTestId(`case-action-popover-button-${basicCase.id}`));
+ });
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-bulk-action-assignees')).toBeInTheDocument();
+ });
+
+ act(() => {
+ userEvent.click(res.getByTestId('cases-bulk-action-assignees'), undefined, {
+ skipPointerEventsCheck: true,
+ });
+ });
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-edit-assignees-flyout')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(res.getByText('Damaged Raccoon')).toBeInTheDocument();
+ });
+
+ userEvent.click(res.getByText('Damaged Raccoon'));
+ userEvent.click(res.getByTestId('cases-edit-assignees-flyout-submit'));
+
+ await waitFor(() => {
+ expect(updateCasesSpy).toHaveBeenCalled();
+ });
+
+ await waitFor(() => {
+ expect(res.queryByTestId('cases-edit-assignees-flyout')).toBeFalsy();
+ });
+ });
+ });
+
describe('Permissions', () => {
it('shows the correct actions with all permissions', async () => {
appMockRender = createAppMockRenderer({ permissions: allCasesPermissions() });
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx
index 66566618302ce..e236a9d552004 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.test.tsx
@@ -23,6 +23,7 @@ import * as api from '../../containers/api';
import { basicCase } from '../../containers/mock';
jest.mock('../../containers/api');
+jest.mock('../../containers/user_profiles/api');
describe('useBulkActions', () => {
let appMockRender: AppMockRenderer;
@@ -45,6 +46,7 @@ describe('useBulkActions', () => {
expect(result.current).toMatchInlineSnapshot(`
Object {
+ "flyouts": ,
"modals": ,
"panels": Array [
Object {
@@ -80,6 +82,17 @@ describe('useBulkActions', () => {
"name": "Edit tags",
"onClick": [Function],
},
+ Object {
+ "data-test-subj": "cases-bulk-action-assignees",
+ "disabled": false,
+ "icon": ,
+ "key": "cases-bulk-action-assignees",
+ "name": "Edit assignees",
+ "onClick": [Function],
+ },
Object {
"data-test-subj": "cases-bulk-action-delete",
"disabled": false,
@@ -348,6 +361,110 @@ describe('useBulkActions', () => {
});
});
+ describe('Flyouts', () => {
+ it('change the tags of the case', async () => {
+ const updateCasesSpy = jest.spyOn(api, 'updateCases');
+
+ const { result, waitFor: waitForHook } = renderHook(
+ () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }),
+ {
+ wrapper: appMockRender.AppWrapper,
+ }
+ );
+
+ let flyouts = result.current.flyouts;
+ const panels = result.current.panels;
+
+ const res = appMockRender.render(
+ <>
+
+ {flyouts}
+ >
+ );
+
+ act(() => {
+ userEvent.click(res.getByTestId('cases-bulk-action-tags'));
+ });
+
+ flyouts = result.current.flyouts;
+
+ res.rerender(
+ <>
+
+ {flyouts}
+ >
+ );
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-edit-tags-flyout')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(res.getByText('coke')).toBeInTheDocument();
+ });
+
+ act(() => {
+ userEvent.click(res.getByText('coke'));
+ userEvent.click(res.getByTestId('cases-edit-tags-flyout-submit'));
+ });
+
+ await waitForHook(() => {
+ expect(updateCasesSpy).toHaveBeenCalled();
+ });
+ });
+
+ it('change the assignees of the case', async () => {
+ const updateCasesSpy = jest.spyOn(api, 'updateCases');
+
+ const { result, waitFor: waitForHook } = renderHook(
+ () => useBulkActions({ onAction, onActionSuccess, selectedCases: [basicCase] }),
+ {
+ wrapper: appMockRender.AppWrapper,
+ }
+ );
+
+ let flyouts = result.current.flyouts;
+ const panels = result.current.panels;
+
+ const res = appMockRender.render(
+ <>
+
+ {flyouts}
+ >
+ );
+
+ act(() => {
+ userEvent.click(res.getByTestId('cases-bulk-action-assignees'));
+ });
+
+ flyouts = result.current.flyouts;
+
+ res.rerender(
+ <>
+
+ {flyouts}
+ >
+ );
+
+ await waitFor(() => {
+ expect(res.getByTestId('cases-edit-assignees-flyout')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(res.getByText('Damaged Raccoon')).toBeInTheDocument();
+ });
+
+ act(() => {
+ userEvent.click(res.getByText('Damaged Raccoon'));
+ userEvent.click(res.getByTestId('cases-edit-assignees-flyout-submit'));
+ });
+
+ await waitForHook(() => {
+ expect(updateCasesSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
describe('Permissions', () => {
it('shows the correct actions with all permissions', async () => {
appMockRender = createAppMockRenderer({ permissions: allCasesPermissions() });
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx
index c5862c832dacf..1d7efd62736fb 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_bulk_actions.tsx
@@ -31,6 +31,7 @@ interface UseBulkActionsProps {
interface UseBulkActionsReturnValue {
panels: EuiContextMenuPanelDescriptor[];
modals: JSX.Element;
+ flyouts: JSX.Element;
}
export const useBulkActions = ({
@@ -156,6 +157,10 @@ export const useBulkActions = ({
onConfirm={deleteAction.onConfirmDeletion}
/>
) : null}
+ >
+ ),
+ flyouts: (
+ <>
{tagsAction.isFlyoutOpen ? (
= React.memo(
refreshCases();
}, [deselectCases, refreshCases]);
- const { panels, modals } = useBulkActions({
+ const { panels, modals, flyouts } = useBulkActions({
selectedCases,
onAction: closePopover,
onActionSuccess: onRefresh,
@@ -135,6 +135,7 @@ export const CasesTableUtilityBar: FunctionComponent = React.memo(
{modals}
+ {flyouts}
>
);
}
diff --git a/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts
index e027466eb1abb..a548b58ee5316 100644
--- a/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts
+++ b/x-pack/plugins/cases/public/containers/user_profiles/__mocks__/api.ts
@@ -6,9 +6,9 @@
*/
import type { UserProfile } from '@kbn/security-plugin/common';
-import { userProfiles } from '../api.mock';
+import { userProfiles, suggestionUserProfiles } from '../api.mock';
-export const suggestUserProfiles = async (): Promise => userProfiles;
+export const suggestUserProfiles = async (): Promise => suggestionUserProfiles;
export const bulkGetUserProfiles = async (): Promise => userProfiles;
diff --git a/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts b/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts
index 42438f16dfa37..5edaedd649489 100644
--- a/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts
+++ b/x-pack/plugins/cases/public/containers/user_profiles/api.mock.ts
@@ -40,6 +40,30 @@ export const userProfiles: UserProfile[] = [
},
];
+export const suggestionUserProfiles: UserProfile[] = [
+ ...userProfiles,
+ {
+ uid: 'u_IbBVXpDtrjOByJ-syBdr425fLGqwpzY_xdQqCFAFXLI_0',
+ enabled: true,
+ data: {},
+ user: {
+ email: 'silly_hare@profiles.elastic.co',
+ full_name: 'Silly Hare',
+ username: 'silly_hare',
+ },
+ },
+ {
+ uid: 'u_topzjXNuXi1aka-8kv3vUylKyK3B21NJq7cSQaMhqDo_0',
+ enabled: true,
+ data: {},
+ user: {
+ email: 'convenient_orca@profiles.elastic.co',
+ full_name: 'Convenient Orca',
+ username: 'convenient_orca',
+ },
+ },
+];
+
export const userProfilesIds = userProfiles.map((profile) => profile.uid);
export const userProfilesMap = new Map(userProfiles.map((profile) => [profile.uid, profile]));
diff --git a/x-pack/test/functional/services/cases/list.ts b/x-pack/test/functional/services/cases/list.ts
index 9394cac0e469e..c214739d0d263 100644
--- a/x-pack/test/functional/services/cases/list.ts
+++ b/x-pack/test/functional/services/cases/list.ts
@@ -290,6 +290,59 @@ export function CasesTableServiceProvider(
await testSubjects.missingOrFail('cases-edit-tags-flyout');
},
+ async bulkEditAssignees(selectedCases: number[], assigneesToClick: string[]) {
+ const rows = await find.allByCssSelector('.euiTableRowCellCheckbox');
+
+ for (const caseIndex of selectedCases) {
+ assertCaseExists(caseIndex, rows.length);
+ rows[caseIndex].click();
+ }
+
+ await this.openBulkActions();
+ await testSubjects.existOrFail('cases-bulk-action-assignees');
+ await testSubjects.click('cases-bulk-action-assignees');
+
+ await testSubjects.existOrFail('cases-edit-assignees-flyout');
+
+ for (const assignee of assigneesToClick) {
+ await testSubjects.existOrFail(
+ `cases-actions-assignees-edit-selectable-assignee-${assignee}`
+ );
+ await testSubjects.click(`cases-actions-assignees-edit-selectable-assignee-${assignee}`);
+ }
+
+ await testSubjects.click('cases-edit-assignees-flyout-submit');
+ await testSubjects.missingOrFail('cases-edit-assignees-flyout');
+ },
+
+ async bulkAddNewAssignees(selectedCases: number[], searchTerm: string) {
+ const rows = await find.allByCssSelector('.euiTableRowCellCheckbox');
+
+ for (const caseIndex of selectedCases) {
+ assertCaseExists(caseIndex, rows.length);
+ rows[caseIndex].click();
+ }
+
+ await this.openBulkActions();
+ await testSubjects.existOrFail('cases-bulk-action-assignees');
+ await testSubjects.click('cases-bulk-action-assignees');
+
+ await testSubjects.existOrFail('cases-edit-assignees-flyout');
+
+ await testSubjects.existOrFail('cases-actions-assignees-edit-selectable-search-input');
+ const searchInput = await testSubjects.find(
+ 'cases-actions-assignees-edit-selectable-search-input'
+ );
+
+ await testSubjects.existOrFail('cases-actions-assignees-edit-selectable-search-input');
+ await searchInput.type(searchTerm);
+
+ await casesCommon.selectFirstRowInAssigneesPopover();
+
+ await testSubjects.click('cases-edit-assignees-flyout-submit');
+ await testSubjects.missingOrFail('cases-edit-assignees-flyout');
+ },
+
async selectAndChangeStatusOfAllCases(status: CaseStatuses) {
await header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('cases-table', { timeout: 20 * 1000 });
diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts
index 98a1f8d2507c2..95e2740c484bb 100644
--- a/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts
+++ b/x-pack/test/functional_with_es_ssl/apps/cases/list_view.ts
@@ -9,6 +9,7 @@ import expect from '@kbn/expect';
import { CaseStatuses } from '@kbn/cases-plugin/common';
import { CaseSeverity } from '@kbn/cases-plugin/common/api';
import { SeverityAll } from '@kbn/cases-plugin/common/ui';
+import { UserProfile } from '@kbn/user-profile-components';
import { FtrProviderContext } from '../../ftr_provider_context';
import {
createUsersAndRoles,
@@ -170,6 +171,112 @@ export default ({ getPageObject, getService }: FtrProviderContext) => {
expect(case1.tags).eql(['one', 'three']);
});
});
+
+ describe('assignees', () => {
+ let caseIds: string[] = [];
+ let profiles: UserProfile[] = [];
+
+ const findAssigneeByUserName = (username: string) =>
+ profiles.find((profile) => profile.user.username === username);
+
+ before(async () => {
+ await createUsersAndRoles(getService, users, roles);
+ await cases.api.activateUserProfiles(users);
+
+ profiles = await cases.api.suggestUserProfiles({
+ name: '',
+ owners: ['cases'],
+ });
+ });
+
+ beforeEach(async () => {
+ caseIds = [];
+ const casesAll = findAssigneeByUserName('cases_all_user')!;
+ const casesAll2 = findAssigneeByUserName('cases_all_user2')!;
+ const casesNoDelete = findAssigneeByUserName('cases_no_delete_user')!;
+
+ const case1 = await cases.api.createCase({
+ title: 'case 1',
+ assignees: [{ uid: casesAll.uid }],
+ });
+
+ const case2 = await cases.api.createCase({
+ title: 'case 2',
+ assignees: [{ uid: casesAll2.uid }, { uid: casesNoDelete.uid }],
+ });
+
+ const case3 = await cases.api.createCase({
+ title: 'case 3',
+ assignees: [{ uid: casesAll2.uid }],
+ });
+
+ caseIds.push(case1.id);
+ caseIds.push(case2.id);
+ caseIds.push(case3.id);
+
+ await header.waitUntilLoadingHasFinished();
+ await cases.casesTable.waitForCasesToBeListed();
+ });
+
+ afterEach(async () => {
+ await cases.api.deleteAllCases();
+ await cases.casesTable.waitForCasesToBeDeleted();
+ });
+
+ after(async () => {
+ await deleteUsersAndRoles(getService, users, roles);
+ });
+
+ it('bulk edit assignees', async () => {
+ const casesAll2 = findAssigneeByUserName('cases_all_user2')!;
+ const casesNoDelete = findAssigneeByUserName('cases_no_delete_user')!;
+ const casesAll = findAssigneeByUserName('cases_all_user')!;
+
+ /**
+ * Case 3 assignees: cases_all_user2
+ * Case 2 assignees: cases_all_user2, cases_no_delete_user
+ * Case 1 assignees: cases_all_user
+ * All assignees: cases_all_user, cases_all_user2, cases_read_delete_user, cases_no_delete_user
+ *
+ * It selects Case 3 and Case 2 because the table orders
+ * the cases in descending order by creation date and clicks
+ * the cases_all_user2, cases_no_delete_user
+ */
+
+ await cases.casesTable.bulkEditAssignees([0, 1], [casesAll2.uid, casesNoDelete.uid]);
+
+ await header.waitUntilLoadingHasFinished();
+
+ const case1 = await cases.api.getCase({ caseId: caseIds[0] });
+ const case2 = await cases.api.getCase({ caseId: caseIds[1] });
+ const case3 = await cases.api.getCase({ caseId: caseIds[2] });
+
+ expect(case3.assignees).eql([{ uid: casesNoDelete.uid }]);
+ expect(case2.assignees).eql([{ uid: casesNoDelete.uid }]);
+ expect(case1.assignees).eql([{ uid: casesAll.uid }]);
+ });
+
+ it('adds a new assignee', async () => {
+ const casesNoDelete = findAssigneeByUserName('cases_no_delete_user')!;
+ const casesAll = findAssigneeByUserName('cases_all_user')!;
+ const casesAll2 = findAssigneeByUserName('cases_all_user2')!;
+
+ await cases.casesTable.bulkAddNewAssignees([0, 1], 'cases all_user');
+ await header.waitUntilLoadingHasFinished();
+
+ const case1 = await cases.api.getCase({ caseId: caseIds[0] });
+ const case2 = await cases.api.getCase({ caseId: caseIds[1] });
+ const case3 = await cases.api.getCase({ caseId: caseIds[2] });
+
+ expect(case3.assignees).eql([{ uid: casesAll2.uid }, { uid: casesAll.uid }]);
+ expect(case2.assignees).eql([
+ { uid: casesAll2.uid },
+ { uid: casesNoDelete.uid },
+ { uid: casesAll.uid },
+ ]);
+ expect(case1.assignees).eql([{ uid: casesAll.uid }]);
+ });
+ });
});
describe('filtering', () => {