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', () => {