From aa18f9887592a227d874e303cff96db944681d2c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 10 Dec 2022 20:28:12 +0200 Subject: [PATCH] Add tests --- .../public/common/use_is_user_typing.test.tsx | 81 +++ .../assignees/use_assignees_action.test.tsx | 144 +++++ .../actions/tags/use_tags_action.test.tsx | 208 ------ .../actions/use_items_action.test.tsx | 355 +++++++++++ .../components/actions/use_items_action.tsx | 11 +- .../actions/use_items_state.test.tsx | 594 ++++++++++++++++++ .../components/actions/use_items_state.tsx | 2 +- 7 files changed, 1182 insertions(+), 213 deletions(-) create mode 100644 x-pack/plugins/cases/public/common/use_is_user_typing.test.tsx create mode 100644 x-pack/plugins/cases/public/components/actions/assignees/use_assignees_action.test.tsx create mode 100644 x-pack/plugins/cases/public/components/actions/use_items_action.test.tsx create mode 100644 x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx diff --git a/x-pack/plugins/cases/public/common/use_is_user_typing.test.tsx b/x-pack/plugins/cases/public/common/use_is_user_typing.test.tsx new file mode 100644 index 0000000000000..229ecc13bba0d --- /dev/null +++ b/x-pack/plugins/cases/public/common/use_is_user_typing.test.tsx @@ -0,0 +1,81 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import type { AppMockRenderer } from './mock'; +import { createAppMockRenderer } from './mock'; +import { useIsUserTyping } from './use_is_user_typing'; + +describe('useIsUserTyping', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + it('set isUserTyping=false on init', () => { + const { result } = renderHook(() => useIsUserTyping(), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.isUserTyping).toBe(false); + }); + + it('set isUserTyping to true with setIsUserTyping', () => { + const { result } = renderHook(() => useIsUserTyping(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.setIsUserTyping(true); + }); + + expect(result.current.isUserTyping).toBe(true); + }); + + it('set isUserTyping to true onContentChange', () => { + const { result } = renderHook(() => useIsUserTyping(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.onContentChange('a value'); + }); + + expect(result.current.isUserTyping).toBe(true); + }); + + it('does not set isUserTyping to true onContentChange when the value is empty', () => { + const { result } = renderHook(() => useIsUserTyping(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.onContentChange(''); + }); + + expect(result.current.isUserTyping).toBe(false); + }); + + it('set isUserTyping to false onDebounce', () => { + const { result } = renderHook(() => useIsUserTyping(), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.setIsUserTyping(true); + }); + + expect(result.current.isUserTyping).toBe(true); + + act(() => { + result.current.onDebounce(); + }); + + expect(result.current.isUserTyping).toBe(false); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/assignees/use_assignees_action.test.tsx b/x-pack/plugins/cases/public/components/actions/assignees/use_assignees_action.test.tsx new file mode 100644 index 0000000000000..91c47edc276c7 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/assignees/use_assignees_action.test.tsx @@ -0,0 +1,144 @@ +/* + * 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 type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useAssigneesAction } from './use_assignees_action'; + +import * as api from '../../../containers/api'; +import { basicCase } from '../../../containers/mock'; + +jest.mock('../../../containers/api'); + +describe('useAssigneesAction', () => { + let appMockRender: AppMockRenderer; + const onAction = jest.fn(); + const onActionSuccess = jest.fn(); + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders an action', async () => { + const { result } = renderHook( + () => + useAssigneesAction({ + onAction, + onActionSuccess, + isDisabled: false, + }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + expect(result.current.getAction([basicCase])).toMatchInlineSnapshot(` + Object { + "data-test-subj": "cases-bulk-action-assignees", + "disabled": false, + "icon": , + "key": "cases-bulk-action-assignees", + "name": "Edit assignees", + "onClick": [Function], + } + `); + }); + + it('update the assignees correctly', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook( + () => useAssigneesAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase]); + + act(() => { + action.onClick(); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveAssignees({ selectedItems: ['1'], unSelectedItems: [] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledWith( + [ + { + assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }, { uid: '1' }], + id: basicCase.id, + version: basicCase.version, + }, + ], + expect.anything() + ); + }); + }); + + it('shows the success toaster correctly when updating one case', async () => { + const { result, waitFor } = renderHook( + () => useAssigneesAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase]); + + act(() => { + action.onClick(); + }); + + act(() => { + result.current.onSaveAssignees({ selectedItems: ['1', '1'], unSelectedItems: ['2'] }); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Edited case' + ); + }); + }); + + it('shows the success toaster correctly when updating multiple cases', async () => { + const { result, waitFor } = renderHook( + () => useAssigneesAction({ onAction, onActionSuccess, isDisabled: false }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + const action = result.current.getAction([basicCase, basicCase]); + + act(() => { + action.onClick(); + }); + + act(() => { + result.current.onSaveAssignees({ selectedItems: ['1', '1'], unSelectedItems: ['2'] }); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'Edited 2 cases' + ); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/tags/use_tags_action.test.tsx b/x-pack/plugins/cases/public/components/actions/tags/use_tags_action.test.tsx index b7aabc04d2c8b..1c4af4705bf38 100644 --- a/x-pack/plugins/cases/public/components/actions/tags/use_tags_action.test.tsx +++ b/x-pack/plugins/cases/public/components/actions/tags/use_tags_action.test.tsx @@ -53,31 +53,6 @@ describe('useTagsAction', () => { `); }); - it('closes the flyout', async () => { - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([basicCase]); - - act(() => { - action.onClick(); - }); - - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onFlyoutClosed(); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - }); - }); - it('update the tags correctly', async () => { const updateSpy = jest.spyOn(api, 'updateCases'); @@ -111,39 +86,6 @@ describe('useTagsAction', () => { }); }); - it('removes duplicates', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([basicCase]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: ['one', 'one'], unSelectedItems: ['pepsi'] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).toHaveBeenCalled(); - expect(updateSpy).toHaveBeenCalledWith( - [{ tags: ['coke', 'one'], id: basicCase.id, version: basicCase.version }], - expect.anything() - ); - }); - }); - it('shows the success toaster correctly when updating one case', async () => { const { result, waitFor } = renderHook( () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), @@ -193,154 +135,4 @@ describe('useTagsAction', () => { ); }); }); - - it('do not update cases with no changes', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([{ ...basicCase, tags: [] }]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: [], unSelectedItems: ['pepsi'] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).not.toHaveBeenCalled(); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - - it('do not update if the selected tags are the same but with different order', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([{ ...basicCase, tags: ['1', '2'] }]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: ['2', '1'], unSelectedItems: [] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).not.toHaveBeenCalled(); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - - it('do not update if the selected tags are the same', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([{ ...basicCase, tags: ['1'] }]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: ['1'], unSelectedItems: [] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).not.toHaveBeenCalled(); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - - it('do not update if selecting and unselecting the same tag', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([{ ...basicCase, tags: ['1'] }]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: ['1'], unSelectedItems: ['1'] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).not.toHaveBeenCalled(); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); - - it('do not update with empty tags and no selection', async () => { - const updateSpy = jest.spyOn(api, 'updateCases'); - - const { result, waitFor } = renderHook( - () => useTagsAction({ onAction, onActionSuccess, isDisabled: false }), - { - wrapper: appMockRender.AppWrapper, - } - ); - - const action = result.current.getAction([{ ...basicCase, tags: [] }]); - - act(() => { - action.onClick(); - }); - - expect(onAction).toHaveBeenCalled(); - expect(result.current.isFlyoutOpen).toBe(true); - - act(() => { - result.current.onSaveTags({ selectedItems: [], unSelectedItems: [] }); - }); - - await waitFor(() => { - expect(result.current.isFlyoutOpen).toBe(false); - expect(onActionSuccess).not.toHaveBeenCalled(); - expect(updateSpy).not.toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/plugins/cases/public/components/actions/use_items_action.test.tsx b/x-pack/plugins/cases/public/components/actions/use_items_action.test.tsx new file mode 100644 index 0000000000000..a53eca39562e8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/use_items_action.test.tsx @@ -0,0 +1,355 @@ +/* + * 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 type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useItemsAction } from './use_items_action'; + +import * as api from '../../containers/api'; +import { basicCase } from '../../containers/mock'; + +jest.mock('../../containers/api'); + +describe('useItemsAction', () => { + let appMockRender: AppMockRenderer; + const onAction = jest.fn(); + const onActionSuccess = jest.fn(); + const successToasterTitle = jest.fn().mockReturnValue('My toaster title'); + const fieldSelector = jest.fn().mockReturnValue(basicCase.tags); + const itemsTransformer = jest.fn().mockImplementation((items) => items); + + const props = { + isDisabled: false, + fieldKey: 'tags' as const, + onAction, + onActionSuccess, + successToasterTitle, + fieldSelector, + itemsTransformer, + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + describe('flyout', () => { + it('opens the flyout', async () => { + const { result } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.isFlyoutOpen).toBe(false); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + expect(result.current.isFlyoutOpen).toBe(true); + }); + + it('closes the flyout', async () => { + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.isFlyoutOpen).toBe(false); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onFlyoutClosed(); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onAction).toHaveBeenCalled(); + }); + }); + }); + + describe('items', () => { + it('update the items correctly', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ + selectedItems: ['one'], + unSelectedItems: ['pepsi'], + }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).toHaveBeenCalled(); + expect(fieldSelector).toHaveBeenCalled(); + expect(itemsTransformer).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledWith( + [ + { + [props.fieldKey]: ['coke', 'one'], + id: basicCase.id, + version: basicCase.version, + }, + ], + expect.anything() + ); + }); + }); + + it('calls fieldSelector correctly', async () => { + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ + selectedItems: ['one'], + unSelectedItems: ['pepsi'], + }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(fieldSelector).toHaveBeenCalledWith(basicCase); + }); + }); + + it('calls itemsTransformer correctly', async () => { + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: [...basicCase.tags, 'one'] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ + selectedItems: ['one'], + unSelectedItems: ['pepsi'], + }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(itemsTransformer).toHaveBeenCalledWith(['coke', 'one']); + }); + }); + + it('removes duplicates', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ + selectedItems: ['one', 'one'], + unSelectedItems: ['pepsi'], + }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).toHaveBeenCalled(); + expect(updateSpy).toHaveBeenCalledWith( + [ + { + [props.fieldKey]: ['coke', 'one'], + id: basicCase.id, + version: basicCase.version, + }, + ], + expect.anything() + ); + }); + }); + + it('shows the success toaster correctly when updating a case', async () => { + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([basicCase]); + }); + + act(() => { + result.current.onSaveItems({ + selectedItems: ['one', 'two'], + unSelectedItems: ['pepsi'], + }); + }); + + await waitFor(() => { + expect(appMockRender.coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + 'My toaster title' + ); + }); + }); + + it('do not update cases with no changes', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: [] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ selectedItems: [], unSelectedItems: ['pepsi'] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + it('do not update if the selected items are the same but with different order', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: ['1', '2'] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ selectedItems: ['2', '1'], unSelectedItems: [] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + it('do not update if the selected items are the same', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: ['1'] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ selectedItems: ['1'], unSelectedItems: [] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + it('do not update if selecting and unselecting the same item', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: ['1'] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ selectedItems: ['1'], unSelectedItems: ['1'] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + + it('do not update with empty items and no selection', async () => { + const updateSpy = jest.spyOn(api, 'updateCases'); + + const { result, waitFor } = renderHook(() => useItemsAction(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.openFlyout([{ ...basicCase, tags: [] }]); + }); + + expect(onAction).toHaveBeenCalled(); + expect(result.current.isFlyoutOpen).toBe(true); + + act(() => { + result.current.onSaveItems({ selectedItems: [], unSelectedItems: [] }); + }); + + await waitFor(() => { + expect(result.current.isFlyoutOpen).toBe(false); + expect(onActionSuccess).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/actions/use_items_action.tsx b/x-pack/plugins/cases/public/components/actions/use_items_action.tsx index 92da9e9615f73..6ab5dce5f48ef 100644 --- a/x-pack/plugins/cases/public/components/actions/use_items_action.tsx +++ b/x-pack/plugins/cases/public/components/actions/use_items_action.tsx @@ -56,23 +56,26 @@ export const useItemsAction = ({ onFlyoutClosed(); const casesToUpdate = selectedCasesToEdit.reduce((acc, theCase) => { + const caseFieldValue = fieldSelector(theCase); + const itemsWithoutUnselectedItems = difference( - fieldSelector(theCase), + caseFieldValue, itemsSelection.unSelectedItems ); - const uniqueTags = new Set([ + + const uniqueItems = new Set([ ...itemsWithoutUnselectedItems, ...itemsSelection.selectedItems, ]); - if (areItemsEqual(new Set([...theCase.tags]), uniqueTags)) { + if (areItemsEqual(new Set([...caseFieldValue]), uniqueItems)) { return acc; } return [ ...acc, { - [fieldKey]: itemsTransformer(Array.from(uniqueTags.values())), + [fieldKey]: itemsTransformer(Array.from(uniqueItems.values())), id: theCase.id, version: theCase.version, }, 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 new file mode 100644 index 0000000000000..e3a14ff49072b --- /dev/null +++ b/x-pack/plugins/cases/public/components/actions/use_items_state.test.tsx @@ -0,0 +1,594 @@ +/* + * 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 type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { useItemsState } from './use_items_state'; + +import { basicCase } from '../../containers/mock'; +import type { ItemSelectableOption } from './types'; + +describe('useItemsState', () => { + let appMockRender: AppMockRenderer; + const onChangeItems = jest.fn(); + const fieldSelector = jest.fn(); + const itemToSelectableOption = jest + .fn() + .mockImplementation((item) => ({ key: item.key, label: item.key, data: item.data })); + + const props = { + items: ['one', 'two', 'three', 'four'], + selectedCases: [basicCase, basicCase], + fieldSelector, + itemToSelectableOption, + onChangeItems, + }; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + fieldSelector.mockReturnValueOnce(['one', 'two']); + fieldSelector.mockReturnValueOnce(['one', 'three']); + jest.clearAllMocks(); + }); + + it('inits the state correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.state).toMatchInlineSnapshot(` + Object { + "itemCounterMap": Map { + "one" => 2, + "two" => 1, + "three" => 1, + }, + "items": Object { + "four": Object { + "data": Object {}, + "dirty": false, + "icon": "empty", + "itemState": "unchecked", + "key": "four", + }, + "one": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "one", + }, + "three": Object { + "data": Object {}, + "dirty": false, + "icon": "asterisk", + "itemState": "partial", + "key": "three", + }, + "two": Object { + "data": Object {}, + "dirty": false, + "icon": "asterisk", + "itemState": "partial", + "key": "two", + }, + }, + } + `); + }); + + it('inits the options correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.options).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-one", + "key": "one", + "label": "one", + }, + Object { + "data": Object { + "itemIcon": "asterisk", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-two", + "key": "two", + "label": "two", + }, + Object { + "data": Object { + "itemIcon": "asterisk", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-three", + "key": "three", + "label": "three", + }, + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-four", + "key": "four", + "label": "four", + }, + ] + `); + }); + + it('inits the totalSelectedItems correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(result.current.totalSelectedItems).toBe(3); + }); + + it('selects all items correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.onSelectAll(); + }); + + expect(result.current.totalSelectedItems).toBe(4); + + expect(result.current.state).toMatchInlineSnapshot(` + Object { + "itemCounterMap": Map { + "one" => 2, + "two" => 1, + "three" => 1, + }, + "items": Object { + "four": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "four", + }, + "one": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "one", + }, + "three": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "three", + }, + "two": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "two", + }, + }, + } + `); + + expect(result.current.options).toMatchInlineSnapshot(` + Array [ + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-one", + "key": "one", + "label": "one", + }, + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-two", + "key": "two", + "label": "two", + }, + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-three", + "key": "three", + "label": "three", + }, + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-four", + "key": "four", + "label": "four", + }, + ] + `); + + expect(onChangeItems).toHaveBeenCalledWith({ + selectedItems: ['one', 'two', 'three', 'four'], + unSelectedItems: [], + }); + }); + + it('unselects all items correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.onSelectNone(); + }); + + expect(result.current.totalSelectedItems).toBe(0); + + expect(result.current.state).toMatchInlineSnapshot(` + Object { + "itemCounterMap": Map { + "one" => 2, + "two" => 1, + "three" => 1, + }, + "items": Object { + "four": Object { + "data": Object {}, + "dirty": false, + "icon": "empty", + "itemState": "unchecked", + "key": "four", + }, + "one": Object { + "data": Object {}, + "dirty": true, + "icon": "empty", + "itemState": "unchecked", + "key": "one", + }, + "three": Object { + "data": Object {}, + "dirty": true, + "icon": "empty", + "itemState": "unchecked", + "key": "three", + }, + "two": Object { + "data": Object {}, + "dirty": true, + "icon": "empty", + "itemState": "unchecked", + "key": "two", + }, + }, + } + `); + + expect(result.current.options).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-one", + "key": "one", + "label": "one", + }, + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-two", + "key": "two", + "label": "two", + }, + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-three", + "key": "three", + "label": "three", + }, + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-four", + "key": "four", + "label": "four", + }, + ] + `); + + expect(onChangeItems).toHaveBeenCalledWith({ + selectedItems: [], + unSelectedItems: ['one', 'two', 'three'], + }); + }); + + it('selects and unselects correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [ + { key: 'one', label: 'one' }, + { checked: 'on', key: 'two', label: 'two' }, + { checked: 'on', key: 'four', label: 'four' }, + ] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + expect(result.current.totalSelectedItems).toBe(3); + + expect(result.current.state).toMatchInlineSnapshot(` + Object { + "itemCounterMap": Map { + "one" => 2, + "two" => 1, + "three" => 1, + }, + "items": Object { + "four": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "four", + }, + "one": Object { + "data": Object {}, + "dirty": true, + "icon": "empty", + "itemState": "unchecked", + "key": "one", + }, + "three": Object { + "data": Object {}, + "dirty": false, + "icon": "asterisk", + "itemState": "partial", + "key": "three", + }, + "two": Object { + "data": Object {}, + "dirty": true, + "icon": "check", + "itemState": "checked", + "key": "two", + }, + }, + } + `); + + expect(result.current.options).toMatchInlineSnapshot(` + Array [ + Object { + "data": Object { + "itemIcon": "empty", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-one", + "key": "one", + "label": "one", + }, + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-two", + "key": "two", + "label": "two", + }, + Object { + "data": Object { + "itemIcon": "asterisk", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-three", + "key": "three", + "label": "three", + }, + Object { + "checked": "on", + "data": Object { + "itemIcon": "check", + }, + "data-test-subj": "cases-actions-items-edit-selectable-item-four", + "key": "four", + "label": "four", + }, + ] + `); + }); + + it('changes the label of the new item correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [ + { key: 'one', label: 'my whatever label', data: { newItem: true } }, + { checked: 'on', key: 'two', label: 'two' }, + { checked: 'on', key: 'four', label: 'four' }, + ] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + const itemOne = result.current.options.find((item) => item.key === 'one')!; + + expect(itemOne.label).toBe('one'); + }); + + it('keeps the data of the option', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [ + { key: 'one', label: 'one', data: { foo: 'bar' } }, + { key: 'two', label: 'two', checked: 'on', data: { baz: 'qux' } }, + { key: 'three', label: 'three', checked: 'on' }, + ] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + const itemOne = result.current.state.items.one; + const itemOneOption = result.current.options.find((item) => item.key === 'one')!; + + const itemTwo = result.current.state.items.two; + const itemTwoOption = result.current.options.find((item) => item.key === 'two')!; + + expect(itemOne.data).toEqual({ foo: 'bar' }); + expect(itemOneOption.data).toEqual({ foo: 'bar', itemIcon: 'empty' }); + + expect(itemTwo.data).toEqual({ baz: 'qux' }); + expect(itemTwoOption.data).toEqual({ baz: 'qux', itemIcon: 'check' }); + }); + + it('does not add the new item as unselected', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [ + { key: 'one', label: 'one', data: { newItem: true } }, + { key: 'two', label: 'two', checked: 'on' }, + ] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + expect(onChangeItems).toBeCalledWith({ + selectedItems: ['two'], + unSelectedItems: [], + }); + }); + + it('does not add non dirty items as unselected', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [{ key: 'two', label: 'two', checked: 'on' }] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + /** + * Item four initial state has dirty=false + * It should not be part of the unSelectedItems + */ + expect(onChangeItems).toBeCalledWith({ + selectedItems: ['two'], + unSelectedItems: [], + }); + }); + + it('calls itemToSelectableOption correctly', async () => { + renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(itemToSelectableOption).toHaveBeenNthCalledWith(1, { + data: {}, + key: 'one', + }); + + expect(itemToSelectableOption).toHaveBeenNthCalledWith(2, { + data: {}, + key: 'two', + }); + + expect(itemToSelectableOption).toHaveBeenNthCalledWith(3, { + data: {}, + key: 'three', + }); + }); + + it('calls itemToSelectableOption with data correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + const newOptions = [ + { key: 'one', label: 'one', data: { foo: 'bar' } }, + { key: 'two', label: 'two', checked: 'on', data: { baz: 'qux' } }, + { key: 'three', label: 'three', checked: 'on' }, + ] as ItemSelectableOption[]; + + act(() => { + result.current.onChange(newOptions); + }); + + expect(itemToSelectableOption).toHaveBeenCalledWith({ key: 'one', data: { foo: 'bar' } }); + expect(itemToSelectableOption).toHaveBeenCalledWith({ key: 'two', data: { baz: 'qux' } }); + }); + + it('defaults the label to key if not returned by itemToSelectableOption', async () => { + itemToSelectableOption.mockImplementation((item) => ({ + key: item.key, + })); + + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + for (const option of result.current.options) { + expect(option.label).toBe(option.label); + } + }); + + it('prevents itemToSelectableOption to override itemIcon', async () => { + itemToSelectableOption.mockImplementation((item) => ({ + key: item.key, + data: { itemIcon: 'my-icon' }, + })); + + const validIcons = ['check', 'asterisk', 'empty']; + + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + for (const option of result.current.options) { + const hasValidIcon = validIcons.some((icon) => icon === option.data?.itemIcon); + expect(hasValidIcon).toBe(true); + } + }); + + it('calls fieldSelector correctly', async () => { + const { result } = renderHook(() => useItemsState(props), { + wrapper: appMockRender.AppWrapper, + }); + + expect(fieldSelector).toHaveBeenCalledWith(basicCase); + }); +}); 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 a135325a1396f..05d8eca72f920 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 @@ -323,7 +323,7 @@ export const useItemsState = ({ (item) => item.itemState === ItemState.CHECKED || item.itemState === ItemState.PARTIAL ).length; - return { state, options, totalSelectedItems, dispatch, onChange, onSelectAll, onSelectNone }; + return { state, options, totalSelectedItems, onChange, onSelectAll, onSelectNone }; }; export type UseItemsState = ReturnType;