From 503b46963b7d1a28e95ccd0b8c238561912e7e54 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 16 May 2022 09:36:15 +0200 Subject: [PATCH] [Cases] Show the Average time to close stat in the all cases page (#131909) --- .../cases/public/api/__mocks__/index.ts | 13 +- .../all_cases/all_cases_list.test.tsx | 19 +++ .../components/all_cases/all_cases_list.tsx | 12 +- .../all_cases/cases_metrics.test.tsx | 50 +++++++ .../components/all_cases/cases_metrics.tsx | 119 ++++++++++++++++ .../public/components/all_cases/count.tsx | 59 -------- .../public/components/all_cases/header.tsx | 40 +----- .../public/components/all_cases/index.tsx | 11 +- .../components/all_cases/translations.ts | 9 ++ .../cases/public/components/status/index.ts | 2 +- .../{stats.test.tsx => status_stats.test.tsx} | 16 ++- .../status/{stats.tsx => status_stats.tsx} | 13 +- .../plugins/cases/public/containers/mock.ts | 5 + .../containers/use_get_cases_metrics.test.tsx | 128 ++++++++++++++++++ .../containers/use_get_cases_metrics.tsx | 92 +++++++++++++ .../cypress/tasks/all_cases.ts | 2 + 16 files changed, 469 insertions(+), 121 deletions(-) create mode 100644 x-pack/plugins/cases/public/components/all_cases/cases_metrics.test.tsx create mode 100644 x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx delete mode 100644 x-pack/plugins/cases/public/components/all_cases/count.tsx rename x-pack/plugins/cases/public/components/status/{stats.test.tsx => status_stats.test.tsx} (75%) rename x-pack/plugins/cases/public/components/status/{stats.tsx => status_stats.tsx} (80%) create mode 100644 x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx diff --git a/x-pack/plugins/cases/public/api/__mocks__/index.ts b/x-pack/plugins/cases/public/api/__mocks__/index.ts index a1df651240224..1632394338a47 100644 --- a/x-pack/plugins/cases/public/api/__mocks__/index.ts +++ b/x-pack/plugins/cases/public/api/__mocks__/index.ts @@ -5,13 +5,20 @@ * 2.0. */ -import { CasesFindRequest } from '../../../common/api'; +import { CasesFindRequest, CasesMetricsRequest } from '../../../common/api'; import { HTTPService } from '..'; -import { casesStatus } from '../../containers/mock'; -import { CasesStatus } from '../../containers/types'; +import { casesMetrics, casesStatus } from '../../containers/mock'; +import { CasesMetrics, CasesStatus } from '../../containers/types'; export const getCasesStatus = async ({ http, signal, query, }: HTTPService & { query: CasesFindRequest }): Promise => Promise.resolve(casesStatus); + +export const getCasesMetrics = async ({ + http, + signal, + query, +}: HTTPService & { query: CasesMetricsRequest }): Promise => + Promise.resolve(casesMetrics); 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 f23dd65d01ec2..853a32eaabbaf 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 @@ -36,12 +36,14 @@ import { waitForComponentToUpdate } from '../../common/test_utils'; import { useCreateAttachments } from '../../containers/use_create_attachments'; import { useGetTags } from '../../containers/use_get_tags'; import { useGetReporters } from '../../containers/use_get_reporters'; +import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; jest.mock('../../containers/use_create_attachments'); jest.mock('../../containers/use_bulk_update_case'); jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +jest.mock('../../containers/use_get_cases_metrics'); jest.mock('../../containers/use_get_action_license'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/use_get_reporters'); @@ -55,6 +57,7 @@ jest.mock('../app/use_available_owners', () => ({ const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; +const useGetCasesMetricsMock = useGetCasesMetrics as jest.Mock; const useUpdateCasesMock = useUpdateCases as jest.Mock; const useGetActionLicenseMock = useGetActionLicense as jest.Mock; const useGetTagsMock = useGetTags as jest.Mock; @@ -118,6 +121,12 @@ describe('AllCasesListGeneric', () => { isLoading: false, }; + const defaultCasesMetrics = { + mttr: 5, + isLoading: false, + fetchCasesMetrics: jest.fn(), + }; + const defaultUpdateCases = { isUpdated: false, isLoading: false, @@ -157,6 +166,7 @@ describe('AllCasesListGeneric', () => { useGetCasesMock.mockReturnValue(defaultGetCases); useDeleteCasesMock.mockReturnValue(defaultDeleteCases); useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); + useGetCasesMetricsMock.mockReturnValue(defaultCasesMetrics); useGetActionLicenseMock.mockReturnValue(defaultActionLicense); useGetTagsMock.mockReturnValue({ tags: ['coke', 'pepsi'], fetchTags: jest.fn() }); useGetReportersMock.mockReturnValue({ @@ -342,6 +352,15 @@ describe('AllCasesListGeneric', () => { }); }); + it('should render the case stats', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="cases-count-stats"]')).toBeTruthy(); + }); + it.skip('Bulk delete', async () => { useGetCasesMock.mockReturnValue({ ...defaultGetCases, 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 96b220283b452..86933b1395b38 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 @@ -27,6 +27,7 @@ import { EuiBasicTableOnChange } from './types'; import { CasesTable } from './table'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { CasesMetrics } from './cases_metrics'; const ProgressLoader = styled(EuiProgress)` ${({ $isShow }: { $isShow: boolean }) => @@ -56,6 +57,7 @@ export const AllCasesList = React.memo( const { owner, userCanCrud } = useCasesContext(); const hasOwner = !!owner.length; const availableSolutions = useAvailableCasesOwners(); + const [refresh, setRefresh] = useState(0); const firstAvailableStatus = head(difference(caseStatuses, hiddenStatuses)); const initialFilterOptions = { @@ -104,8 +106,13 @@ export const AllCasesList = React.memo( const refreshCases = useCallback( (dataRefresh = true) => { deselectCases(); - if (dataRefresh) refetchCases(); - if (doRefresh) doRefresh(); + if (dataRefresh) { + refetchCases(); + setRefresh((currRefresh: number) => currRefresh + 1); + } + if (doRefresh) { + doRefresh(); + } if (filterRefetch.current != null) { filterRefetch.current(); } @@ -206,6 +213,7 @@ export const AllCasesList = React.memo( className="essentialAnimation" $isShow={(isCasesLoading || isLoading) && !isDataEmpty} /> + { + useGetCasesStatusMock.mockReturnValue({ + countOpenCases: 2, + countInProgressCases: 3, + countClosedCases: 4, + isLoading: false, + fetchCasesStatus: jest.fn(), + }); + useGetCasesMetricsMock.mockReturnValue({ + // 600 seconds = 10m + mttr: 600, + isLoading: false, + fetchCasesMetrics: jest.fn(), + }); + + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + appMockRenderer = createAppMockRenderer(); + }); + + it('renders the correct stats', () => { + const result = appMockRenderer.render(); + expect(result.getByTestId('cases-metrics-stats')).toBeTruthy(); + expect(within(result.getByTestId('openStatsHeader')).getByText(2)).toBeTruthy(); + expect(within(result.getByTestId('inProgressStatsHeader')).getByText(3)).toBeTruthy(); + expect(within(result.getByTestId('closedStatsHeader')).getByText(4)).toBeTruthy(); + expect(within(result.getByTestId('mttrStatsHeader')).getByText('10m')).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx new file mode 100644 index 0000000000000..3325b6de4ebcc --- /dev/null +++ b/x-pack/plugins/cases/public/components/all_cases/cases_metrics.tsx @@ -0,0 +1,119 @@ +/* + * 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, { FunctionComponent, useEffect, useMemo } from 'react'; +import { + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, + EuiToolTip, +} from '@elastic/eui'; +import styled, { css } from 'styled-components'; +import prettyMilliseconds from 'pretty-ms'; +import { CaseStatuses } from '../../../common/api'; +import { useGetCasesStatus } from '../../containers/use_get_cases_status'; +import { StatusStats } from '../status/status_stats'; +import { useGetCasesMetrics } from '../../containers/use_get_cases_metrics'; +import { ATTC_DESCRIPTION, ATTC_STAT } from './translations'; + +interface CountProps { + refresh?: number; +} +const MetricsFlexGroup = styled.div` + ${({ theme }) => css` + .euiFlexGroup { + border: ${theme.eui.euiBorderThin}; + border-radius: ${theme.eui.euiBorderRadius}; + margin: 0 0 ${theme.eui.euiSizeL} 0; + } + @media only screen and (max-width: ${theme.eui.euiBreakpoints.s}) { + .euiFlexGroup { + padding: ${theme.eui.euiSizeM}; + } + } + `} +`; + +export const CasesMetrics: FunctionComponent = ({ refresh }) => { + const { + countOpenCases, + countInProgressCases, + countClosedCases, + isLoading: isCasesStatusLoading, + fetchCasesStatus, + } = useGetCasesStatus(); + + const { mttr, isLoading: isCasesMetricsLoading, fetchCasesMetrics } = useGetCasesMetrics(); + + const mttrValue = useMemo( + () => (mttr ? prettyMilliseconds(mttr * 1000, { compact: true, verbose: false }) : '-'), + [mttr] + ); + + useEffect(() => { + if (refresh != null) { + fetchCasesStatus(); + fetchCasesMetrics(); + } + }, [fetchCasesMetrics, fetchCasesStatus, refresh]); + + return ( + + + + + + + + + + + + + + <> + {ATTC_STAT} + + + ), + description: isCasesMetricsLoading ? ( + + ) : ( + mttrValue + ), + }, + ]} + /> + + + + ); +}; +CasesMetrics.displayName = 'CasesMetrics'; diff --git a/x-pack/plugins/cases/public/components/all_cases/count.tsx b/x-pack/plugins/cases/public/components/all_cases/count.tsx deleted file mode 100644 index cd4abdd08b8e7..0000000000000 --- a/x-pack/plugins/cases/public/components/all_cases/count.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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, { FunctionComponent, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { CaseStatuses } from '../../../common/api'; -import { Stats } from '../status'; -import { useGetCasesStatus } from '../../containers/use_get_cases_status'; - -interface CountProps { - refresh?: number; -} -export const Count: FunctionComponent = ({ refresh }) => { - const { - countOpenCases, - countInProgressCases, - countClosedCases, - isLoading: isCasesStatusLoading, - fetchCasesStatus, - } = useGetCasesStatus(); - useEffect(() => { - if (refresh != null) { - fetchCasesStatus(); - } - }, [fetchCasesStatus, refresh]); - return ( - - - - - - - - - - - - ); -}; -Count.displayName = 'Count'; diff --git a/x-pack/plugins/cases/public/components/all_cases/header.tsx b/x-pack/plugins/cases/public/components/all_cases/header.tsx index 4e66083711e2b..9a02594a790fa 100644 --- a/x-pack/plugins/cases/public/components/all_cases/header.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/header.tsx @@ -7,60 +7,26 @@ import React, { FunctionComponent } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import styled, { css } from 'styled-components'; import { HeaderPage } from '../header_page'; import * as i18n from './translations'; -import { Count } from './count'; import { ErrorMessage } from '../use_push_to_service/callout/types'; import { NavButtons } from './nav_buttons'; interface OwnProps { actionsErrors: ErrorMessage[]; - refresh: number; userCanCrud: boolean; } type Props = OwnProps; -const FlexItemDivider = styled(EuiFlexItem)` - ${({ theme }) => css` - .euiFlexGroup--gutterMedium > &.euiFlexItem { - border-right: ${theme.eui.euiBorderThin}; - padding-right: ${theme.eui.euiSize}; - margin-right: ${theme.eui.euiSize}; - @media only screen and (max-width: ${theme.eui.euiBreakpoints.l}) { - padding-right: 0; - border-right: 0; - margin-right: 0; - } - } - `} -`; - -export const CasesTableHeader: FunctionComponent = ({ - actionsErrors, - refresh, - userCanCrud, -}) => ( +export const CasesTableHeader: FunctionComponent = ({ actionsErrors, userCanCrud }) => ( {userCanCrud ? ( - <> - - - - - - - - - ) : ( - // doesn't include the horizontal bar that divides the buttons and other padding since we don't have any buttons - // to the right - + - )} + ) : null} ); diff --git a/x-pack/plugins/cases/public/components/all_cases/index.tsx b/x-pack/plugins/cases/public/components/all_cases/index.tsx index 8ea7681eb44d9..c2811df9a684d 100644 --- a/x-pack/plugins/cases/public/components/all_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/all_cases/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { CasesDeepLinkId } from '../../common/navigation'; import { useGetActionLicense } from '../../containers/use_get_action_license'; import { useCasesContext } from '../cases_context/use_cases_context'; @@ -16,20 +16,15 @@ import { CasesTableHeader } from './header'; export const AllCases: React.FC = () => { const { userCanCrud } = useCasesContext(); - const [refresh, setRefresh] = useState(0); useCasesBreadcrumbs(CasesDeepLinkId.cases); - const doRefresh = useCallback(() => { - setRefresh((prev) => prev + 1); - }, [setRefresh]); - const { actionLicense } = useGetActionLicense(); const actionsErrors = useMemo(() => getActionLicenseError(actionLicense), [actionLicense]); return ( <> - - + + ); }; diff --git a/x-pack/plugins/cases/public/components/all_cases/translations.ts b/x-pack/plugins/cases/public/components/all_cases/translations.ts index 4b6a86a2592e6..e56ac8b0da655 100644 --- a/x-pack/plugins/cases/public/components/all_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/all_cases/translations.ts @@ -105,3 +105,12 @@ export const STATUS = i18n.translate('xpack.cases.caseTable.status', { export const CHANGE_STATUS = i18n.translate('xpack.cases.caseTable.changeStatus', { defaultMessage: 'Change status', }); + +export const ATTC_STAT = i18n.translate('xpack.cases.casesStats.mttr', { + defaultMessage: 'Average time to close', +}); + +export const ATTC_DESCRIPTION = i18n.translate('xpack.cases.casesStats.mttrDescription', { + defaultMessage: + 'Average time to close is the average duration of cases from creation to closure.', +}); diff --git a/x-pack/plugins/cases/public/components/status/index.ts b/x-pack/plugins/cases/public/components/status/index.ts index 94d7cb6a31830..a261b903ae9ce 100644 --- a/x-pack/plugins/cases/public/components/status/index.ts +++ b/x-pack/plugins/cases/public/components/status/index.ts @@ -7,5 +7,5 @@ export * from './status'; export * from './config'; -export * from './stats'; +export * from './status_stats'; export * from './types'; diff --git a/x-pack/plugins/cases/public/components/status/stats.test.tsx b/x-pack/plugins/cases/public/components/status/status_stats.test.tsx similarity index 75% rename from x-pack/plugins/cases/public/components/status/stats.test.tsx rename to x-pack/plugins/cases/public/components/status/status_stats.test.tsx index ea0f54bf8055b..28292496dd917 100644 --- a/x-pack/plugins/cases/public/components/status/stats.test.tsx +++ b/x-pack/plugins/cases/public/components/status/status_stats.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { CaseStatuses } from '../../../common/api'; -import { Stats } from './stats'; +import { StatusStats } from './status_stats'; describe('Stats', () => { const defaultProps = { @@ -19,13 +19,13 @@ describe('Stats', () => { dataTestSubj: 'test-stats', }; it('it renders', async () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find(`[data-test-subj="test-stats"]`).exists()).toBeTruthy(); }); it('shows the count', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__description`).first().text() @@ -33,14 +33,14 @@ describe('Stats', () => { }); it('shows the loading spinner', async () => { - const wrapper = mount(); + const wrapper = mount(); expect(wrapper.find(`[data-test-subj="test-stats-loading-spinner"]`).exists()).toBeTruthy(); }); describe('Status title', () => { it('shows the correct title for status open', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() @@ -48,7 +48,9 @@ describe('Stats', () => { }); it('shows the correct title for status in-progress', async () => { - const wrapper = mount(); + const wrapper = mount( + + ); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() @@ -56,7 +58,7 @@ describe('Stats', () => { }); it('shows the correct title for status closed', async () => { - const wrapper = mount(); + const wrapper = mount(); expect( wrapper.find(`[data-test-subj="test-stats"] .euiDescriptionList__title`).first().text() diff --git a/x-pack/plugins/cases/public/components/status/stats.tsx b/x-pack/plugins/cases/public/components/status/status_stats.tsx similarity index 80% rename from x-pack/plugins/cases/public/components/status/stats.tsx rename to x-pack/plugins/cases/public/components/status/status_stats.tsx index 98720ad75a656..56f4259f87ea6 100644 --- a/x-pack/plugins/cases/public/components/status/stats.tsx +++ b/x-pack/plugins/cases/public/components/status/status_stats.tsx @@ -17,7 +17,12 @@ export interface Props { dataTestSubj?: string; } -const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dataTestSubj }) => { +const StatusStatsComponent: React.FC = ({ + caseCount, + caseStatus, + isLoading, + dataTestSubj, +}) => { const statusStats = useMemo( () => [ { @@ -25,7 +30,7 @@ const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dat description: isLoading ? ( ) : ( - caseCount ?? 'N/A' + caseCount ?? '-' ), }, ], @@ -36,5 +41,5 @@ const StatsComponent: React.FC = ({ caseCount, caseStatus, isLoading, dat ); }; -StatsComponent.displayName = 'StatsComponent'; -export const Stats = memo(StatsComponent); +StatusStatsComponent.displayName = 'StatusStats'; +export const StatusStats = memo(StatusStatsComponent); diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index ed9e9ebd1ff8f..8cf413d08f2fd 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -12,6 +12,7 @@ import type { SingleCaseMetrics, SingleCaseMetricsFeature, AlertComment, + CasesMetrics, } from '../../common/ui/types'; import { Actions, @@ -292,6 +293,10 @@ export const casesStatus: CasesStatus = { countClosedCases: 130, }; +export const casesMetrics: CasesMetrics = { + mttr: 12, +}; + export const basicPush = { connectorId: '123', connectorName: 'connector name', diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx new file mode 100644 index 0000000000000..6601a104d9f7d --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.test.tsx @@ -0,0 +1,128 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import * as api from '../api'; +import { TestProviders } from '../common/mock'; +import { useGetCasesMetrics, UseGetCasesMetrics } from './use_get_cases_metrics'; +import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; + +jest.mock('../api'); +jest.mock('../common/lib/kibana'); + +describe('useGetReporters', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('init', async () => { + const { result } = renderHook(() => useGetCasesMetrics(), { + wrapper: ({ children }) => {children}, + }); + + await act(async () => { + expect(result.current).toEqual({ + mttr: 0, + isLoading: true, + isError: false, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); + + it('calls getCasesMetrics api', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + await act(async () => { + const { waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(spy).toBeCalledWith({ + http: expect.anything(), + signal: expect.anything(), + query: { + features: ['mttr'], + owner: [SECURITY_SOLUTION_OWNER], + }, + }); + }); + }); + + it('fetch cases metrics', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(result.current).toEqual({ + mttr: 12, + isLoading: false, + isError: false, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); + + it('fetches metrics when fetchCasesMetrics is invoked', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + + await waitForNextUpdate(); + expect(spy).toBeCalledWith({ + http: expect.anything(), + signal: expect.anything(), + query: { + features: ['mttr'], + owner: [SECURITY_SOLUTION_OWNER], + }, + }); + result.current.fetchCasesMetrics(); + await waitForNextUpdate(); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + it('unhappy path', async () => { + const spy = jest.spyOn(api, 'getCasesMetrics'); + spy.mockImplementation(() => { + throw new Error('Oh on. this is impossible'); + }); + + await act(async () => { + const { result, waitForNextUpdate } = renderHook( + () => useGetCasesMetrics(), + { + wrapper: ({ children }) => {children}, + } + ); + await waitForNextUpdate(); + + expect(result.current).toEqual({ + mttr: 0, + isLoading: false, + isError: true, + fetchCasesMetrics: result.current.fetchCasesMetrics, + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx new file mode 100644 index 0000000000000..a5cb116acc559 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_cases_metrics.tsx @@ -0,0 +1,92 @@ +/* + * 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 { useCallback, useEffect, useState, useRef } from 'react'; + +import { useCasesContext } from '../components/cases_context/use_cases_context'; +import * as i18n from './translations'; +import { useHttp, useToasts } from '../common/lib/kibana'; +import { getCasesMetrics } from '../api'; +import { CasesMetrics } from './types'; + +interface CasesMetricsState extends CasesMetrics { + isLoading: boolean; + isError: boolean; +} + +const initialData: CasesMetricsState = { + mttr: 0, + isLoading: true, + isError: false, +}; + +export interface UseGetCasesMetrics extends CasesMetricsState { + fetchCasesMetrics: () => void; +} + +export const useGetCasesMetrics = (): UseGetCasesMetrics => { + const http = useHttp(); + const { owner } = useCasesContext(); + const [casesMetricsState, setCasesMetricsState] = useState(initialData); + const toasts = useToasts(); + const isCancelledRef = useRef(false); + const abortCtrlRef = useRef(new AbortController()); + + const fetchCasesMetrics = useCallback(async () => { + try { + isCancelledRef.current = false; + abortCtrlRef.current.abort(); + abortCtrlRef.current = new AbortController(); + setCasesMetricsState({ + ...initialData, + isLoading: true, + }); + + const response = await getCasesMetrics({ + http, + signal: abortCtrlRef.current.signal, + query: { owner, features: ['mttr'] }, + }); + + if (!isCancelledRef.current) { + setCasesMetricsState({ + ...response, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!isCancelledRef.current) { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + setCasesMetricsState({ + mttr: 0, + isLoading: false, + isError: true, + }); + } + } + }, [http, owner, toasts]); + + useEffect(() => { + fetchCasesMetrics(); + + return () => { + isCancelledRef.current = true; + abortCtrlRef.current.abort(); + }; + }, [fetchCasesMetrics]); + + return { + ...casesMetricsState, + fetchCasesMetrics, + }; +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/all_cases.ts b/x-pack/plugins/security_solution/cypress/tasks/all_cases.ts index b2db9232beb1e..a4368de3c8d42 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/all_cases.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/all_cases.ts @@ -10,12 +10,14 @@ import { ALL_CASES_CREATE_NEW_CASE_BTN, EDIT_EXTERNAL_CONNECTION, } from '../screens/all_cases'; +import { waitForPageToBeLoaded } from './common'; export const goToCreateNewCase = () => { cy.get(ALL_CASES_CREATE_NEW_CASE_BTN).click({ force: true }); }; export const goToCaseDetails = () => { + waitForPageToBeLoaded(); cy.get(ALL_CASES_NAME).click({ force: true }); };