diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx index ed219e4b9dee07..39031fae9e3a53 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.test.tsx @@ -13,7 +13,7 @@ import { renderHook } from '@testing-library/react-hooks'; import userEvent from '@testing-library/user-event'; import '../../common/mock/match_media'; -import { TestProviders } from '../../common/mock'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import { casesStatus, useGetCasesMockState, mockCase, connectorsMock } from '../../containers/mock'; import { StatusAll } from '../../../common/ui/types'; @@ -21,7 +21,6 @@ import { CaseSeverity, CaseStatuses } from '../../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; -import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useUpdateCases } from '../../containers/use_bulk_update_case'; import { useKibana } from '../../common/lib/kibana'; @@ -36,6 +35,8 @@ import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; +import { useUpdateCase } from '../../containers/use_update_case'; +import { useGetCases } from '../../containers/use_get_cases'; jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_bulk_update_case'); @@ -52,6 +53,7 @@ jest.mock('../../common/navigation/hooks'); jest.mock('../app/use_available_owners', () => ({ useAvailableCasesOwners: () => ['securitySolution', 'observability'], })); +jest.mock('../../containers/use_update_case'); const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; @@ -63,6 +65,7 @@ const useGetReportersMock = useGetReporters as jest.Mock; const useKibanaMock = useKibana as jest.MockedFunction; const useGetConnectorsMock = useGetConnectors as jest.Mock; const useCreateAttachmentsMock = useCreateAttachments as jest.Mock; +const useUpdateCaseMock = useUpdateCase as jest.Mock; const mockTriggersActionsUiService = triggersActionsUiMock.createStart(); @@ -78,16 +81,14 @@ const mockKibana = () => { describe('AllCasesListGeneric', () => { const dispatchResetIsDeleted = jest.fn(); const dispatchResetIsUpdated = jest.fn(); - const dispatchUpdateCaseProperty = jest.fn(); const handleOnDeleteConfirm = jest.fn(); const handleToggleModal = jest.fn(); const refetchCases = jest.fn(); - const setFilters = jest.fn(); - const setQueryParams = jest.fn(); - const setSelectedCases = jest.fn(); const updateBulkStatus = jest.fn(); const fetchCasesStatus = jest.fn(); const onRowClick = jest.fn(); + const updateCaseProperty = jest.fn(); + const emptyTag = getEmptyTagValue().props.children; useCreateAttachmentsMock.mockReturnValue({ status: { isLoading: false }, @@ -96,11 +97,7 @@ describe('AllCasesListGeneric', () => { const defaultGetCases = { ...useGetCasesMockState, - dispatchUpdateCaseProperty, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, + refetch: refetchCases, }; const defaultDeleteCases = { @@ -138,7 +135,6 @@ describe('AllCasesListGeneric', () => { href: jest.fn(), onClick: jest.fn(), }, - dispatchUpdateCaseProperty: jest.fn, filterStatus: CaseStatuses.open, handleIsLoading: jest.fn(), isLoadingCases: [], @@ -146,6 +142,8 @@ describe('AllCasesListGeneric', () => { userCanCrud: true, }; + let appMockRenderer: AppMockRenderer; + beforeAll(() => { mockKibana(); const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; @@ -154,6 +152,7 @@ describe('AllCasesListGeneric', () => { beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); useUpdateCasesMock.mockReturnValue(defaultUpdateCases); useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); @@ -168,16 +167,12 @@ describe('AllCasesListGeneric', () => { fetchReporters: jest.fn(), }); useGetConnectorsMock.mockImplementation(() => ({ data: connectorsMock, isLoading: false })); + useUpdateCaseMock.mockReturnValue({ updateCaseProperty }); mockKibana(); moment.tz.setDefault('UTC'); }); it('should render AllCasesList', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - }); - const wrapper = mount( @@ -216,10 +211,6 @@ describe('AllCasesListGeneric', () => { }); it('should show a tooltip with the reporter username when hover over the reporter avatar', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - }); const result = render( @@ -237,10 +228,6 @@ describe('AllCasesListGeneric', () => { }); it('should show a tooltip with all tags when hovered', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - }); const result = render( @@ -257,7 +244,6 @@ describe('AllCasesListGeneric', () => { it('should render empty fields', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, data: { ...defaultGetCases.data, cases: [ @@ -307,10 +293,6 @@ describe('AllCasesListGeneric', () => { }); it('should render delete actions for case', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - }); const wrapper = mount( @@ -329,12 +311,16 @@ describe('AllCasesListGeneric', () => { ); wrapper.find('[data-test-subj="tableHeaderSortButton"]').first().simulate('click'); await waitFor(() => { - expect(setQueryParams).toBeCalledWith({ - page: 1, - perPage: 5, - sortField: 'createdAt', - sortOrder: 'asc', - }); + expect(useGetCasesMock).toBeCalledWith( + expect.objectContaining({ + queryParams: { + page: 1, + perPage: 5, + sortField: 'createdAt', + sortOrder: 'asc', + }, + }) + ); }); }); @@ -352,14 +338,12 @@ describe('AllCasesListGeneric', () => { await waitFor(() => { const firstCase = useGetCasesMockState.data.cases[0]; - expect(dispatchUpdateCaseProperty.mock.calls[0][0]).toEqual( - expect.objectContaining({ - caseId: firstCase.id, - updateKey: 'status', - updateValue: CaseStatuses.closed, - version: firstCase.version, - }) - ); + expect(updateCaseProperty).toHaveBeenCalledWith({ + caseData: firstCase, + updateKey: 'status', + updateValue: CaseStatuses.closed, + onSuccess: expect.anything(), + }); }); }); @@ -373,12 +357,6 @@ describe('AllCasesListGeneric', () => { }); it.skip('Bulk delete', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, - selectedCases: [...useGetCasesMockState.data.cases, mockCase], - }); - useDeleteCasesMock .mockReturnValueOnce({ ...defaultDeleteCases, @@ -419,18 +397,13 @@ describe('AllCasesListGeneric', () => { }); it('Renders only bulk delete on status all', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: StatusAll }, - selectedCases: [...useGetCasesMockState.data.cases], - }); - const wrapper = mount( ); + wrapper.find('[data-test-subj*="checkboxSelectRow-"]').first().simulate('click'); wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); await waitFor(() => { @@ -441,72 +414,44 @@ describe('AllCasesListGeneric', () => { expect(wrapper.find('[data-test-subj="cases-bulk-close-button"]').exists()).toEqual(false); expect( wrapper.find('[data-test-subj="cases-bulk-delete-button"]').first().props().disabled - ).toEqual(false); + ).toEqual(true); }); }); it('Bulk close status update', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - selectedCases: useGetCasesMockState.data.cases, - }); - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); - wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click'); - - await waitFor(() => { - expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed); - }); + const result = appMockRenderer.render(); + const theCase = useGetCasesMockState.data.cases[0]; + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-in-progress')); + userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); + userEvent.click(result.getByText('Bulk actions')); + userEvent.click(result.getByTestId('cases-bulk-close-button')); + await waitFor(() => {}); + expect(updateBulkStatus).toBeCalledWith([theCase], CaseStatuses.closed); }); it('Bulk open status update', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - selectedCases: useGetCasesMockState.data.cases, - filterOptions: { - ...defaultGetCases.filterOptions, - status: CaseStatuses.closed, - }, - }); - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); - wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click'); - await waitFor(() => { - expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open); - }); + const result = appMockRenderer.render(); + const theCase = useGetCasesMockState.data.cases[0]; + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-closed')); + userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); + userEvent.click(result.getByText('Bulk actions')); + userEvent.click(result.getByTestId('cases-bulk-open-button')); + await waitFor(() => {}); + expect(updateBulkStatus).toBeCalledWith([theCase], CaseStatuses.open); }); it('Bulk in-progress status update', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.open }, - selectedCases: useGetCasesMockState.data.cases, - }); - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click'); - wrapper.find('[data-test-subj="cases-bulk-in-progress-button"]').first().simulate('click'); - await waitFor(() => { - expect(updateBulkStatus).toBeCalledWith( - useGetCasesMockState.data.cases, - CaseStatuses['in-progress'] - ); - }); + const result = appMockRenderer.render(); + const theCase = useGetCasesMockState.data.cases[0]; + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-closed')); + userEvent.click(result.getByTestId(`checkboxSelectRow-${theCase.id}`)); + userEvent.click(result.getByText('Bulk actions')); + userEvent.click(result.getByTestId('cases-bulk-in-progress-button')); + await waitFor(() => {}); + expect(updateBulkStatus).toBeCalledWith([theCase], CaseStatuses['in-progress']); }); it('isDeleted is true, refetch', async () => { @@ -597,22 +542,12 @@ describe('AllCasesListGeneric', () => { }); }); - it('should call onRowClick with no cases and isSelectorView=true', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - data: { - ...defaultGetCases.data, - total: 0, - cases: [], - }, - }); - - const wrapper = mount( - - - + it('should call onRowClick with no cases and isSelectorView=true when create case is clicked', async () => { + const result = appMockRenderer.render( + ); - wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click'); + userEvent.click(result.getByTestId('cases-table-add-case-filter-bar')); + await waitFor(() => { expect(onRowClick).toHaveBeenCalled(); }); @@ -688,47 +623,56 @@ describe('AllCasesListGeneric', () => { }); it('should change the status to closed', async () => { - const wrapper = mount( - - - - ); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click'); + const result = appMockRenderer.render(); + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-closed')); await waitFor(() => { - expect(setQueryParams).toBeCalledWith({ - sortField: 'closedAt', - }); + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + page: 1, + perPage: 5, + sortField: 'closedAt', + sortOrder: 'desc', + }, + }) + ); }); }); it('should change the status to in-progress', async () => { - const wrapper = mount( - - - - ); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').simulate('click'); + const result = appMockRenderer.render(); + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-in-progress')); await waitFor(() => { - expect(setQueryParams).toBeCalledWith({ - sortField: 'createdAt', - }); + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + page: 1, + perPage: 5, + sortField: 'createdAt', + sortOrder: 'desc', + }, + }) + ); }); }); it('should change the status to open', async () => { - const wrapper = mount( - - - - ); - wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click'); - wrapper.find('button[data-test-subj="case-status-filter-open"]').simulate('click'); + const result = appMockRenderer.render(); + userEvent.click(result.getByTestId('case-status-filter')); + userEvent.click(result.getByTestId('case-status-filter-in-progress')); await waitFor(() => { - expect(setQueryParams).toBeCalledWith({ - sortField: 'createdAt', - }); + expect(useGetCasesMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + queryParams: { + page: 1, + perPage: 5, + sortField: 'createdAt', + sortOrder: 'desc', + }, + }) + ); }); }); @@ -836,11 +780,6 @@ describe('AllCasesListGeneric', () => { }); it('should deselect cases when refreshing', async () => { - useGetCasesMock.mockReturnValue({ - ...defaultGetCases, - selectedCases: [], - }); - render( diff --git a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx index 4417b10754f5f1..4dc2223537d6d3 100644 --- a/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/all_cases_list.tsx @@ -14,10 +14,11 @@ import { Case, CaseStatusWithAllStatus, FilterOptions, + QueryParams, SortFieldCase, + StatusAll, } from '../../../common/ui/types'; import { CaseStatuses, caseStatuses } from '../../../common/api'; -import { useGetCases } from '../../containers/use_get_cases'; import { useAvailableCasesOwners } from '../app/use_available_owners'; import { useCasesColumns } from './columns'; @@ -28,6 +29,12 @@ import { CasesTable } from './table'; import { useCasesContext } from '../cases_context/use_cases_context'; import { CasesMetrics } from './cases_metrics'; import { useGetConnectors } from '../../containers/configure/use_connectors'; +import { + DEFAULT_FILTER_OPTIONS, + DEFAULT_QUERY_PARAMS, + initialData, + useGetCases, +} from '../../containers/use_get_cases'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -65,19 +72,21 @@ export const AllCasesList = React.memo( ...(!isEmpty(hiddenStatuses) && firstAvailableStatus && { status: firstAvailableStatus }), owner: hasOwner ? owner : availableSolutions, }; + const [filterOptions, setFilterOptions] = useState({ + ...DEFAULT_FILTER_OPTIONS, + ...initialFilterOptions, + }); + const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_PARAMS); + const [selectedCases, setSelectedCases] = useState([]); const { - data, - dispatchUpdateCaseProperty, + data = initialData, + isFetching: isLoadingCases, + refetch: refetchCases, + } = useGetCases({ filterOptions, - loading, queryParams, - selectedCases, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - } = useGetCases({ initialFilterOptions }); + }); const { data: connectors = [] } = useGetConnectors(); @@ -147,30 +156,40 @@ export const AllCasesList = React.memo( const onFilterChangedCallback = useCallback( (newFilterOptions: Partial) => { if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) { - setQueryParams({ sortField: SortFieldCase.closedAt }); + setQueryParams((prevQueryParams) => ({ + ...prevQueryParams, + sortField: SortFieldCase.closedAt, + })); } else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) { - setQueryParams({ sortField: SortFieldCase.createdAt }); + setQueryParams((prevQueryParams) => ({ + ...prevQueryParams, + sortField: SortFieldCase.createdAt, + })); } else if ( newFilterOptions.status && newFilterOptions.status === CaseStatuses['in-progress'] ) { - setQueryParams({ sortField: SortFieldCase.createdAt }); + setQueryParams((prevQueryParams) => ({ + ...prevQueryParams, + sortField: SortFieldCase.createdAt, + })); } deselectCases(); - setFilters(newFilterOptions); + setFilterOptions((prevFilterOptions) => ({ + ...prevFilterOptions, + ...newFilterOptions, + })); refreshCases(false); }, - [deselectCases, setFilters, refreshCases, setQueryParams] + [deselectCases, setFilterOptions, refreshCases, setQueryParams] ); const showActions = userCanCrud && !isSelectorView; const columns = useCasesColumns({ - dispatchUpdateCaseProperty, - filterStatus: filterOptions.status, + filterStatus: filterOptions.status ?? StatusAll, handleIsLoading, - isLoadingCases: loading, refreshCases, isSelectorView, userCanCrud, @@ -181,9 +200,9 @@ export const AllCasesList = React.memo( const pagination = useMemo( () => ({ - pageIndex: queryParams.page - 1, - pageSize: queryParams.perPage, - totalItemCount: data.total, + pageIndex: (queryParams?.page ?? DEFAULT_QUERY_PARAMS.page) - 1, + pageSize: queryParams?.perPage ?? DEFAULT_QUERY_PARAMS.perPage, + totalItemCount: data.total ?? 0, pageSizeOptions: [5, 10, 15, 20, 25], }), [data, queryParams] @@ -196,7 +215,6 @@ export const AllCasesList = React.memo( }), [selectedCases, setSelectedCases] ); - const isCasesLoading = useMemo(() => loading.indexOf('cases') > -1, [loading]); const isDataEmpty = useMemo(() => data.total === 0, [data]); const tableRowProps = useCallback( @@ -212,7 +230,7 @@ export const AllCasesList = React.memo( size="xs" color="accent" className="essentialAnimation" - $isShow={(isCasesLoading || isLoading) && !isDataEmpty} + $isShow={isLoading || isLoadingCases} /> {!isSelectorView ? : null} ( filterOptions={filterOptions} goToCreateCase={onRowClick} handleIsLoading={handleIsLoading} - isCasesLoading={isCasesLoading} - isCommentUpdating={isCasesLoading} + isCasesLoading={isLoadingCases} + isCommentUpdating={isLoadingCases} isDataEmpty={isDataEmpty} isSelectorView={isSelectorView} onChange={tableOnChangeCallback} diff --git a/x-pack/plugins/cases/public/components/all_cases/columns.tsx b/x-pack/plugins/cases/public/components/all_cases/columns.tsx index 05345fb05d009b..5d161caff5a17b 100644 --- a/x-pack/plugins/cases/public/components/all_cases/columns.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/columns.tsx @@ -24,7 +24,7 @@ import { import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import styled from 'styled-components'; -import { Case, DeleteCase } from '../../../common/ui/types'; +import { Case, DeleteCase, UpdateByKey } from '../../../common/ui/types'; import { CaseStatuses, ActionConnector, CaseSeverity } from '../../../common/api'; import { OWNER_INFO } from '../../../common/constants'; import { getEmptyTagValue } from '../empty_value'; @@ -33,7 +33,6 @@ import { CaseDetailsLink } from '../links'; import * as i18n from './translations'; import { ALERTS } from '../../common/translations'; import { getActions } from './actions'; -import { UpdateCase } from '../../containers/use_get_cases'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { useApplicationCapabilities, useKibana } from '../../common/lib/kibana'; @@ -43,6 +42,7 @@ import { getConnectorIcon } from '../utils'; import type { CasesOwners } from '../../client/helpers/can_use_cases'; import { useCasesFeatures } from '../cases_context/use_cases_features'; import { severities } from '../severity/config'; +import { useUpdateCase } from '../../containers/use_update_case'; export type CasesColumns = | EuiTableActionsColumnType @@ -57,10 +57,8 @@ const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); export interface GetCasesColumn { - dispatchUpdateCaseProperty: (u: UpdateCase) => void; filterStatus: string; handleIsLoading: (a: boolean) => void; - isLoadingCases: string[]; refreshCases?: (a?: boolean) => void; isSelectorView: boolean; userCanCrud: boolean; @@ -70,10 +68,8 @@ export interface GetCasesColumn { showSolutionColumn?: boolean; } export const useCasesColumns = ({ - dispatchUpdateCaseProperty, filterStatus, handleIsLoading, - isLoadingCases, refreshCases, isSelectorView, userCanCrud, @@ -98,6 +94,8 @@ export const useCasesColumns = ({ title: '', }); + const { updateCaseProperty, isLoading: isLoadingUpdateCase } = useUpdateCase(); + const toggleDeleteModal = useCallback( (deleteCase: Case) => { handleToggleModal(); @@ -107,15 +105,17 @@ export const useCasesColumns = ({ ); const handleDispatchUpdate = useCallback( - (args: Omit) => { - dispatchUpdateCaseProperty({ - ...args, - refetchCasesStatus: () => { + ({ updateKey, updateValue, caseData }: UpdateByKey) => { + updateCaseProperty({ + updateKey, + updateValue, + caseData, + onSuccess: () => { if (refreshCases != null) refreshCases(); }, }); }, - [dispatchUpdateCaseProperty, refreshCases] + [refreshCases, updateCaseProperty] ); const actions = useMemo( @@ -136,8 +136,8 @@ export const useCasesColumns = ({ ); useEffect(() => { - handleIsLoading(isDeleting || isLoadingCases.indexOf('caseUpdate') > -1); - }, [handleIsLoading, isDeleting, isLoadingCases]); + handleIsLoading(isDeleting || isLoadingUpdateCase); + }, [handleIsLoading, isDeleting, isLoadingUpdateCase]); useEffect(() => { if (isDeleted) { @@ -319,13 +319,12 @@ export const useCasesColumns = ({ return ( 0} + disabled={!userCanCrud || isLoadingUpdateCase} onStatusChanged={(status) => handleDispatchUpdate({ updateKey: 'status', updateValue: status, - caseId: theCase.id, - version: theCase.version, + caseData: theCase, }) } /> diff --git a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx index bbf575d6693061..fb9d4a62d82da1 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.test.tsx @@ -13,12 +13,11 @@ import { AllCases } from '.'; import { TestProviders } from '../../common/mock'; import { useGetReporters } from '../../containers/use_get_reporters'; import { useGetActionLicense } from '../../containers/use_get_action_license'; -import { CaseStatuses } from '../../../common/api'; import { casesStatus, connectorsMock, useGetCasesMockState } from '../../containers/mock'; -import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesStatus } from '../../containers/use_get_cases_status'; import { useGetConnectors } from '../../containers/configure/use_connectors'; import { useGetTags } from '../../containers/use_get_tags'; +import { useGetCases } from '../../containers/use_get_cases'; jest.mock('../../containers/use_get_reporters'); jest.mock('../../containers/use_get_tags'); @@ -38,7 +37,6 @@ const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; describe('AllCases', () => { - const dispatchUpdateCaseProperty = jest.fn(); const refetchCases = jest.fn(); const setFilters = jest.fn(); const setQueryParams = jest.fn(); @@ -47,7 +45,6 @@ describe('AllCases', () => { const defaultGetCases = { ...useGetCasesMockState, - dispatchUpdateCaseProperty, refetchCases, setFilters, setQueryParams, @@ -89,7 +86,6 @@ describe('AllCases', () => { it('should render the stats', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, - filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed }, }); const wrapper = mount( diff --git a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts index 3b65e9b767d205..33620c91d87a2d 100644 --- a/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts +++ b/x-pack/plugins/cases/public/components/case_view/use_on_update_field.ts @@ -15,7 +15,7 @@ import { getTypedPayload } from '../../containers/utils'; import { OnUpdateFields } from './types'; export const useOnUpdateField = ({ caseData, caseId }: { caseData: Case; caseId: string }) => { - const { isLoading, updateKey: loadingKey, updateCaseProperty } = useUpdateCase({ caseId }); + const { isLoading, updateKey: loadingKey, updateCaseProperty } = useUpdateCase(); const onUpdateField = useCallback( ({ key, value, onSuccess, onError }: OnUpdateFields) => { diff --git a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx index 74b3a463982926..d25f3b997aeda0 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/index.test.tsx @@ -10,9 +10,9 @@ import { configure } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import RecentCases, { RecentCasesProps } from '.'; import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; -import { useGetCases } from '../../containers/use_get_cases'; import { useGetCasesMockState } from '../../containers/mock'; import { useCurrentUser } from '../../common/lib/kibana/hooks'; +import { useGetCases } from '../../containers/use_get_cases'; jest.mock('../../containers/use_get_cases'); jest.mock('../../common/lib/kibana/hooks'); @@ -23,10 +23,8 @@ const defaultProps: RecentCasesProps = { maxCasesToShow: 10, }; -const setFilters = jest.fn(); const mockData = { ...useGetCasesMockState, - setFilters, }; const useGetCasesMock = useGetCases as jest.Mock; @@ -45,10 +43,10 @@ describe('RecentCases', () => { appMockRender = createAppMockRenderer(); }); - it('is good at loading', () => { + it('shows a loading status', () => { useGetCasesMock.mockImplementation(() => ({ ...mockData, - loading: 'cases', + isLoading: true, })); const { getAllByTestId } = appMockRender.render( @@ -59,7 +57,7 @@ describe('RecentCases', () => { expect(getAllByTestId('loadingPlaceholders')).toHaveLength(3); }); - it('is good at rendering cases', () => { + it('render cases', () => { const { getAllByTestId } = appMockRender.render( @@ -68,43 +66,50 @@ describe('RecentCases', () => { expect(getAllByTestId('case-details-link')).toHaveLength(7); }); - it('is good at rendering max cases', () => { + it('render max cases correctly', () => { appMockRender.render( ); - expect(useGetCasesMock).toBeCalledWith({ - initialQueryParams: { perPage: 2 }, + expect(useGetCasesMock).toHaveBeenCalledWith({ + filterOptions: { reporters: [] }, + queryParams: { perPage: 2 }, }); }); - it('updates filters', () => { + it('sets the reporter filters correctly', () => { const { getByTestId } = appMockRender.render( ); - const element = getByTestId('myRecentlyReported'); - userEvent.click(element); - expect(setFilters).toHaveBeenCalled(); - }); - - it('it resets the reporters when changing from my recently reported cases to recent cases', () => { - const { getByTestId } = appMockRender.render( - - - - ); + expect(useGetCasesMock).toHaveBeenCalledWith({ + filterOptions: { reporters: [] }, + queryParams: { perPage: 10 }, + }); + // apply the filter const myRecentCasesElement = getByTestId('myRecentlyReported'); - const recentCasesElement = getByTestId('recentlyCreated'); userEvent.click(myRecentCasesElement); + + expect(useGetCasesMock).toHaveBeenLastCalledWith({ + filterOptions: { + reporters: [{ email: undefined, full_name: undefined, username: undefined }], + }, + queryParams: { perPage: 10 }, + }); + + // remove the filter + const recentCasesElement = getByTestId('recentlyCreated'); userEvent.click(recentCasesElement); - const mockCalls = setFilters.mock.calls; - expect(mockCalls[0][0].reporters.length).toBeGreaterThan(0); - expect(mockCalls[1][0]).toEqual({ reporters: [] }); + expect(useGetCasesMock).toHaveBeenLastCalledWith({ + filterOptions: { + reporters: [], + }, + queryParams: { perPage: 10 }, + }); }); }); diff --git a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx index 231bf666d625ed..44eee0c0b23f87 100644 --- a/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx +++ b/x-pack/plugins/cases/public/components/recent_cases/recent_cases.tsx @@ -6,19 +6,18 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; -import React, { useEffect, useMemo, useRef } from 'react'; -import { isEqual } from 'lodash/fp'; +import React from 'react'; import styled from 'styled-components'; import { IconWithCount } from './icon_with_count'; import * as i18n from './translations'; -import { useGetCases } from '../../containers/use_get_cases'; import { CaseDetailsLink } from '../links'; import { LoadingPlaceholders } from './loading_placeholders'; import { NoCases } from './no_cases'; import { MarkdownRenderer } from '../markdown_editor'; import { FilterOptions } from '../../containers/types'; import { TruncatedText } from '../truncated_text'; +import { initialData as initialGetCasesData, useGetCases } from '../../containers/use_get_cases'; const MarkdownContainer = styled.div` max-height: 150px; @@ -31,31 +30,12 @@ export interface RecentCasesProps { maxCasesToShow: number; } -const usePrevious = (value: Partial) => { - const ref = useRef(); - useEffect(() => { - (ref.current as unknown) = value; - }); - return ref.current; -}; - export const RecentCasesComp = ({ filterOptions, maxCasesToShow }: RecentCasesProps) => { - const previousFilterOptions = usePrevious(filterOptions); - const { data, loading, setFilters } = useGetCases({ - initialQueryParams: { perPage: maxCasesToShow }, + const { data = initialGetCasesData, isLoading: isLoadingCases } = useGetCases({ + queryParams: { perPage: maxCasesToShow }, + filterOptions, }); - useEffect(() => { - if (previousFilterOptions !== undefined && !isEqual(previousFilterOptions, filterOptions)) { - setFilters(filterOptions); - } - }, [previousFilterOptions, filterOptions, setFilters]); - - const isLoadingCases = useMemo( - () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, - [loading] - ); - return isLoadingCases ? ( ) : !isLoadingCases && data.cases.length === 0 ? ( diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 1d056d166263ba..4967db2beb3cdc 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -35,7 +35,6 @@ import { CaseSeverity, } from '../../common/api'; import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; -import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; import { SnakeToCamelCase } from '../../common/types'; import { covertToSnakeCase } from './utils'; @@ -680,13 +679,10 @@ export const caseUserActions: CaseUserActions[] = [ ]; // components tests -export const useGetCasesMockState: UseGetCasesState = { +export const useGetCasesMockState = { data: allCases, - loading: [], - selectedCases: [], + isLoading: false, isError: false, - queryParams: DEFAULT_QUERY_PARAMS, - filterOptions: DEFAULT_FILTER_OPTIONS, }; export const basicCaseClosed: Case = { diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx index b689746a7af001..8d2947a3d351cf 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.test.tsx @@ -5,21 +5,10 @@ * 2.0. */ -import React from 'react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseSeverity, CaseStatuses } from '../../common/api'; -import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; -import { - DEFAULT_FILTER_OPTIONS, - DEFAULT_QUERY_PARAMS, - initialData, - useGetCases, - UseGetCases, -} from './use_get_cases'; -import { UpdateKey } from './types'; -import { allCases, basicCase, caseWithAlerts, caseWithAlertsSyncOff } from './mock'; +import { renderHook } from '@testing-library/react-hooks'; +import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS, useGetCases } from './use_get_cases'; import * as api from './api'; -import { TestProviders } from '../common/mock'; +import { AppMockRenderer, createAppMockRenderer } from '../common/mock'; import { useToasts } from '../common/lib/kibana'; jest.mock('./api'); @@ -30,256 +19,39 @@ describe('useGetCases', () => { const addSuccess = jest.fn(); (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() }); + let appMockRender: AppMockRenderer; + beforeEach(() => { + appMockRender = createAppMockRenderer(); jest.clearAllMocks(); }); - it('init', async () => { - const { result } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - - await act(async () => { - expect(result.current).toEqual({ - data: initialData, - dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, - filterOptions: DEFAULT_FILTER_OPTIONS, - isError: false, - loading: ['cases'], - queryParams: DEFAULT_QUERY_PARAMS, - refetchCases: result.current.refetchCases, - selectedCases: [], - setFilters: result.current.setFilters, - setQueryParams: result.current.setQueryParams, - setSelectedCases: result.current.setSelectedCases, - }); - }); - }); - it('calls getCases with correct arguments', async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); - await act(async () => { - const { waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - expect(spyOnGetCases).toBeCalledWith({ - filterOptions: { ...DEFAULT_FILTER_OPTIONS }, - queryParams: DEFAULT_QUERY_PARAMS, - signal: abortCtrl.signal, - }); - }); - }); - - it('fetch cases', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - expect(result.current).toEqual({ - data: allCases, - dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, - filterOptions: DEFAULT_FILTER_OPTIONS, - isError: false, - loading: [], - queryParams: DEFAULT_QUERY_PARAMS, - refetchCases: result.current.refetchCases, - selectedCases: [], - setFilters: result.current.setFilters, - setQueryParams: result.current.setQueryParams, - setSelectedCases: result.current.setSelectedCases, - }); - }); - }); - - it('dispatch update case property', async () => { - const spyOnPatchCase = jest.spyOn(api, 'patchCase'); - await act(async () => { - const updateCase = { - updateKey: 'description' as UpdateKey, - updateValue: 'description update', - caseId: basicCase.id, - refetchCasesStatus: jest.fn(), - version: '99999', - }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.dispatchUpdateCaseProperty(updateCase); - expect(result.current.loading).toEqual(['caseUpdate']); - expect(spyOnPatchCase).toBeCalledWith( - basicCase.id, - { [updateCase.updateKey]: updateCase.updateValue }, - updateCase.version, - abortCtrl.signal - ); - }); - expect(addSuccess).toHaveBeenCalledWith({ - title: `Updated "${basicCase.title}"`, - }); - }); - - it('shows a success toast notifying of synced alerts when sync is on', async () => { - await act(async () => { - const updateCase = { - updateKey: 'status' as UpdateKey, - updateValue: 'open', - caseId: caseWithAlerts.id, - refetchCasesStatus: jest.fn(), - version: '99999', - }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.dispatchUpdateCaseProperty(updateCase); + const { waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: appMockRender.AppWrapper, }); - expect(addSuccess).toHaveBeenCalledWith({ - text: 'Updated the statuses of attached alerts.', - title: 'Updated "Another horrible breach!!"', + await waitForNextUpdate(); + expect(spyOnGetCases).toBeCalledWith({ + filterOptions: { ...DEFAULT_FILTER_OPTIONS }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, }); }); - it('shows a success toast without notifying of synced alerts when sync is off', async () => { - await act(async () => { - const updateCase = { - updateKey: 'status' as UpdateKey, - updateValue: 'open', - caseId: caseWithAlertsSyncOff.id, - refetchCasesStatus: jest.fn(), - version: '99999', - }; - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.dispatchUpdateCaseProperty(updateCase); - }); - expect(addSuccess).toHaveBeenCalledWith({ - title: 'Updated "Another horrible breach!!"', - }); - }); - - it('refetch cases', async () => { - const spyOnGetCases = jest.spyOn(api, 'getCases'); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.refetchCases(); - expect(spyOnGetCases).toHaveBeenCalledTimes(2); - }); - }); - - it('set isLoading to true when refetching case', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.refetchCases(); - - expect(result.current.loading).toEqual(['cases']); - }); - }); - - it('unhappy path', async () => { + it('shows a toast error message when an error occurs in the response', async () => { const spyOnGetCases = jest.spyOn(api, 'getCases'); spyOnGetCases.mockImplementation(() => { throw new Error('Something went wrong'); }); + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - - expect(result.current).toEqual({ - data: initialData, - dispatchUpdateCaseProperty: result.current.dispatchUpdateCaseProperty, - filterOptions: DEFAULT_FILTER_OPTIONS, - isError: true, - loading: [], - queryParams: DEFAULT_QUERY_PARAMS, - refetchCases: result.current.refetchCases, - selectedCases: [], - setFilters: result.current.setFilters, - setQueryParams: result.current.setQueryParams, - setSelectedCases: result.current.setSelectedCases, - }); + const { waitForNextUpdate } = renderHook(() => useGetCases(), { + wrapper: appMockRender.AppWrapper, }); - }); - - it('set filters', async () => { - await act(async () => { - const spyOnGetCases = jest.spyOn(api, 'getCases'); - const newFilters = { - search: 'new', - severity: CaseSeverity.LOW, - tags: ['new'], - status: CaseStatuses.closed, - owner: [SECURITY_SOLUTION_OWNER], - }; - - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - - await waitForNextUpdate(); - result.current.setFilters(newFilters); - await waitForNextUpdate(); - expect(spyOnGetCases.mock.calls[1][0]).toEqual({ - filterOptions: { - ...DEFAULT_FILTER_OPTIONS, - ...newFilters, - owner: [SECURITY_SOLUTION_OWNER], - }, - queryParams: DEFAULT_QUERY_PARAMS, - signal: abortCtrl.signal, - }); - }); - }); - - it('set query params', async () => { - await act(async () => { - const spyOnGetCases = jest.spyOn(api, 'getCases'); - const newQueryParams = { - page: 2, - }; - - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - - await waitForNextUpdate(); - result.current.setQueryParams(newQueryParams); - await waitForNextUpdate(); - - expect(spyOnGetCases.mock.calls[1][0]).toEqual({ - filterOptions: { ...DEFAULT_FILTER_OPTIONS }, - queryParams: { - ...DEFAULT_QUERY_PARAMS, - ...newQueryParams, - }, - signal: abortCtrl.signal, - }); - }); - }); - - it('set selected cases', async () => { - await act(async () => { - const selectedCases = [basicCase]; - const { result, waitForNextUpdate } = renderHook(() => useGetCases(), { - wrapper: ({ children }) => {children}, - }); - await waitForNextUpdate(); - result.current.setSelectedCases(selectedCases); - expect(result.current.selectedCases).toEqual(selectedCases); - }); + await waitForNextUpdate(); + expect(addError).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_cases.tsx index f708d982822528..ee20951e0151d4 100644 --- a/x-pack/plugins/cases/public/containers/use_get_cases.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_cases.tsx @@ -5,100 +5,13 @@ * 2.0. */ -import { useCallback, useEffect, useReducer, useRef } from 'react'; -import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; -import { - Cases, - Case, - FilterOptions, - QueryParams, - SortFieldCase, - StatusAll, - UpdateByKey, - SeverityAll, -} from './types'; +import { useQuery, UseQueryResult } from 'react-query'; +import { CASE_LIST_CACHE_KEY, DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants'; +import { Cases, FilterOptions, QueryParams, SortFieldCase, StatusAll, SeverityAll } from './types'; import { useToasts } from '../common/lib/kibana'; import * as i18n from './translations'; -import { getCases, patchCase } from './api'; - -export interface UseGetCasesState { - data: Cases; - filterOptions: FilterOptions; - isError: boolean; - loading: string[]; - queryParams: QueryParams; - selectedCases: Case[]; -} - -export interface UpdateCase extends Omit { - caseId: string; - version: string; - refetchCasesStatus: () => void; -} - -export type Action = - | { type: 'FETCH_INIT'; payload: string } - | { - type: 'FETCH_CASES_SUCCESS'; - payload: Cases; - } - | { type: 'FETCH_FAILURE'; payload: string } - | { type: 'FETCH_UPDATE_CASE_SUCCESS' } - | { type: 'UPDATE_FILTER_OPTIONS'; payload: Partial } - | { type: 'UPDATE_QUERY_PARAMS'; payload: Partial } - | { type: 'UPDATE_TABLE_SELECTIONS'; payload: Case[] }; - -const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesState => { - switch (action.type) { - case 'FETCH_INIT': - return { - ...state, - isError: false, - loading: [...state.loading.filter((e) => e !== action.payload), action.payload], - }; - case 'FETCH_UPDATE_CASE_SUCCESS': - return { - ...state, - loading: state.loading.filter((e) => e !== 'caseUpdate'), - }; - case 'FETCH_CASES_SUCCESS': - return { - ...state, - data: action.payload, - isError: false, - loading: state.loading.filter((e) => e !== 'cases'), - }; - case 'FETCH_FAILURE': - return { - ...state, - isError: true, - loading: state.loading.filter((e) => e !== action.payload), - }; - case 'UPDATE_FILTER_OPTIONS': - return { - ...state, - filterOptions: { - ...state.filterOptions, - ...action.payload, - }, - }; - case 'UPDATE_QUERY_PARAMS': - return { - ...state, - queryParams: { - ...state.queryParams, - ...action.payload, - }, - }; - case 'UPDATE_TABLE_SELECTIONS': - return { - ...state, - selectedCases: action.payload, - }; - default: - return state; - } -}; +import { getCases } from './api'; +import { ServerError } from '../types'; export const DEFAULT_FILTER_OPTIONS: FilterOptions = { search: '', @@ -125,157 +38,40 @@ export const initialData: Cases = { perPage: 0, total: 0, }; -export interface UseGetCases extends UseGetCasesState { - dispatchUpdateCaseProperty: ({ - updateKey, - updateValue, - caseId, - version, - refetchCasesStatus, - }: UpdateCase) => void; - refetchCases: () => void; - setFilters: (filters: Partial) => void; - setQueryParams: (queryParams: Partial) => void; - setSelectedCases: (mySelectedCases: Case[]) => void; -} -const empty = {}; export const useGetCases = ( params: { - initialQueryParams?: Partial; - initialFilterOptions?: Partial; + queryParams?: Partial; + filterOptions?: Partial; } = {} -): UseGetCases => { - const { initialQueryParams = empty, initialFilterOptions = empty } = params; - const [state, dispatch] = useReducer(dataFetchReducer, { - data: initialData, - filterOptions: { - ...DEFAULT_FILTER_OPTIONS, - ...initialFilterOptions, - }, - isError: false, - loading: [], - queryParams: { ...DEFAULT_QUERY_PARAMS, ...initialQueryParams }, - selectedCases: [], - }); +): UseQueryResult => { const toasts = useToasts(); - const didCancelFetchCases = useRef(false); - const didCancelUpdateCases = useRef(false); - const abortCtrlFetchCases = useRef(new AbortController()); - const abortCtrlUpdateCases = useRef(new AbortController()); - - const setSelectedCases = useCallback((mySelectedCases: Case[]) => { - dispatch({ type: 'UPDATE_TABLE_SELECTIONS', payload: mySelectedCases }); - }, []); - - const setQueryParams = useCallback((newQueryParams: Partial) => { - dispatch({ type: 'UPDATE_QUERY_PARAMS', payload: newQueryParams }); - }, []); - - const setFilters = useCallback((newFilters: Partial) => { - dispatch({ type: 'UPDATE_FILTER_OPTIONS', payload: newFilters }); - }, []); - - const fetchCases = useCallback( - async (filterOptions: FilterOptions, queryParams: QueryParams) => { - try { - didCancelFetchCases.current = false; - abortCtrlFetchCases.current.abort(); - abortCtrlFetchCases.current = new AbortController(); - dispatch({ type: 'FETCH_INIT', payload: 'cases' }); - - const response = await getCases({ - filterOptions, - queryParams, - signal: abortCtrlFetchCases.current.signal, - }); - - if (!didCancelFetchCases.current) { - dispatch({ - type: 'FETCH_CASES_SUCCESS', - payload: response, - }); - } - } catch (error) { - if (!didCancelFetchCases.current) { - if (error.name !== 'AbortError') { - toasts.addError( - error.body && error.body.message ? new Error(error.body.message) : error, - { title: i18n.ERROR_TITLE } - ); - } - dispatch({ type: 'FETCH_FAILURE', payload: 'cases' }); - } - } + return useQuery( + [CASE_LIST_CACHE_KEY, 'cases', params], + () => { + const abortCtrl = new AbortController(); + return getCases({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + ...(params.filterOptions ?? {}), + }, + queryParams: { + ...DEFAULT_QUERY_PARAMS, + ...(params.queryParams ?? {}), + }, + signal: abortCtrl.signal, + }); }, - [toasts] - ); - - const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue, caseId, refetchCasesStatus, version }: UpdateCase) => { - const caseData = state.data.cases.find((caseInfo) => caseInfo.id === caseId); - try { - didCancelUpdateCases.current = false; - abortCtrlUpdateCases.current.abort(); - abortCtrlUpdateCases.current = new AbortController(); - dispatch({ type: 'FETCH_INIT', payload: 'caseUpdate' }); - - await patchCase( - caseId, - { [updateKey]: updateValue }, - // saved object versions are typed as string | undefined, hope that's not true - version ?? '', - abortCtrlUpdateCases.current.signal - ); - - if (!didCancelUpdateCases.current) { - dispatch({ type: 'FETCH_UPDATE_CASE_SUCCESS' }); - fetchCases(state.filterOptions, state.queryParams); - refetchCasesStatus(); - if (caseData) { - toasts.addSuccess({ - title: i18n.UPDATED_CASE(caseData.title), - text: - updateKey === 'status' && caseData.totalAlerts > 0 && caseData.settings.syncAlerts - ? i18n.STATUS_CHANGED_TOASTER_TEXT - : undefined, - }); - } + { + keepPreviousData: true, + onError: (error: ServerError) => { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); } - } catch (error) { - if (!didCancelUpdateCases.current) { - if (error.name !== 'AbortError') { - toasts.addError(error, { title: i18n.ERROR_TITLE }); - } - dispatch({ type: 'FETCH_FAILURE', payload: 'caseUpdate' }); - } - } - }, - [fetchCases, state.data, state.filterOptions, state.queryParams, toasts] + }, + } ); - - const refetchCases = useCallback(() => { - fetchCases(state.filterOptions, state.queryParams); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.filterOptions, state.queryParams]); - - useEffect(() => { - fetchCases(state.filterOptions, state.queryParams); - return () => { - didCancelFetchCases.current = true; - didCancelUpdateCases.current = true; - abortCtrlFetchCases.current.abort(); - abortCtrlUpdateCases.current.abort(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [state.queryParams, state.filterOptions]); - - return { - ...state, - dispatchUpdateCaseProperty, - refetchCases, - setFilters, - setQueryParams, - setSelectedCases, - }; }; diff --git a/x-pack/plugins/cases/public/containers/use_update_case.test.tsx b/x-pack/plugins/cases/public/containers/use_update_case.test.tsx index f3fc3caa3718d4..28bfeca01d446a 100644 --- a/x-pack/plugins/cases/public/containers/use_update_case.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_case.test.tsx @@ -37,7 +37,7 @@ describe('useUpdateCase', () => { it('init', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useUpdateCase({ caseId: basicCase.id }) + useUpdateCase() ); await waitForNextUpdate(); expect(result.current).toEqual({ @@ -54,7 +54,7 @@ describe('useUpdateCase', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useUpdateCase({ caseId: basicCase.id }) + useUpdateCase() ); await waitForNextUpdate(); @@ -72,7 +72,7 @@ describe('useUpdateCase', () => { it('patch case and refresh the case page', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useUpdateCase({ caseId: basicCase.id }) + useUpdateCase() ); await waitForNextUpdate(); result.current.updateCaseProperty(sampleUpdate); @@ -91,7 +91,7 @@ describe('useUpdateCase', () => { it('set isLoading to true when posting case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useUpdateCase({ caseId: basicCase.id }) + useUpdateCase() ); await waitForNextUpdate(); result.current.updateCaseProperty(sampleUpdate); @@ -109,7 +109,7 @@ describe('useUpdateCase', () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => - useUpdateCase({ caseId: basicCase.id }) + useUpdateCase() ); await waitForNextUpdate(); result.current.updateCaseProperty(sampleUpdate); diff --git a/x-pack/plugins/cases/public/containers/use_update_case.tsx b/x-pack/plugins/cases/public/containers/use_update_case.tsx index ac358ec0a2bbcb..4b7433ca794741 100644 --- a/x-pack/plugins/cases/public/containers/use_update_case.tsx +++ b/x-pack/plugins/cases/public/containers/use_update_case.tsx @@ -57,7 +57,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => export interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; } -export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => { +export const useUpdateCase = (): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { isLoading: false, isError: false, @@ -77,7 +77,7 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => dispatch({ type: 'FETCH_INIT', payload: updateKey }); const response = await patchCase( - caseId, + caseData.id, { [updateKey]: updateValue }, caseData.version, abortCtrlRef.current.signal @@ -110,7 +110,7 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [caseId] + [] ); useEffect(