[Cases] Case details UI KPI metrics (#119463)

* Working lifespan metrics api

* new case metrics container and api call

* Adding remaining metrics handlers and some tests

* Fixing jest snapshot

* Switch to kbn archiver

* tests added, case view page refactor

* test for metrics component added

* fix type

* fix responsivenes on small screens

* type fixes

* use new features prop for case metrics

* test fixed

* fix CasesFeatures type

* integration test fix

* changes and suggestions

* metrics features implementation and connectors type

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2021-12-15 16:49:47 +01:00 committed by GitHub
parent ce153c597d
commit 52a9d60e3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1806 additions and 1131 deletions

View file

@ -7,6 +7,7 @@
import {
CASE_DETAILS_URL,
CASE_METRICS_DETAILS_URL,
CASE_COMMENTS_URL,
CASE_USER_ACTIONS_URL,
CASE_COMMENT_DETAILS_URL,
@ -22,6 +23,10 @@ export const getCaseDetailsUrl = (id: string): string => {
return CASE_DETAILS_URL.replace('{case_id}', id);
};
export const getCaseDetailsMetricsUrl = (id: string): string => {
return CASE_METRICS_DETAILS_URL.replace('{case_id}', id);
};
export const getSubCasesUrl = (caseID: string): string => {
return SUB_CASES_URL.replace('{case_id}', caseID);
};

View file

@ -64,13 +64,12 @@ export const CaseMetricsResponseRt = rt.partial(
/**
* External connectors associated with the case
*/
connectors: rt.array(
rt.type({
id: rt.string,
name: rt.string,
pushCount: rt.number,
})
),
connectors: rt.type({
/**
* Total number of connectors in the case
*/
total: rt.number,
}),
/**
* The case's open,close,in-progress details
*/

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { ConnectorTypes } from './api';
import { CasesContextValue } from './ui/types';
import { CasesContextFeatures } from './ui/types';
export const DEFAULT_DATE_FORMAT = 'dateFormat';
export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz';
@ -110,6 +110,7 @@ export const MAX_TITLE_LENGTH = 64;
* Cases features
*/
export const DEFAULT_FEATURES: CasesContextValue['features'] = Object.freeze({
export const DEFAULT_FEATURES: CasesContextFeatures = Object.freeze({
alerts: { sync: true },
metrics: [],
});

View file

@ -19,13 +19,7 @@ export { CASES_URL, SECURITY_SOLUTION_OWNER, ENABLE_CASE_CONNECTOR } from './con
export { CommentType, CaseStatuses, getCasesFromAlertsUrl, throwErrors } from './api';
export type {
SubCase,
Case,
Ecs,
CasesContextValue,
CaseViewRefreshPropInterface,
} from './ui/types';
export type { SubCase, Case, Ecs, CasesFeatures, CaseViewRefreshPropInterface } from './ui/types';
export { StatusAll } from './ui/types';

View file

@ -17,19 +17,23 @@ import {
UserAction,
UserActionField,
ActionConnector,
CaseMetricsResponse,
} from '../api';
interface CasesFeatures {
export interface CasesContextFeatures {
alerts: { sync: boolean };
metrics: CaseMetricsFeature[];
}
export type CasesFeatures = Partial<CasesContextFeatures>;
export interface CasesContextValue {
owner: string[];
appId: string;
appTitle: string;
userCanCrud: boolean;
basePath: string;
features: CasesFeatures;
features: CasesContextFeatures;
}
export interface CasesUiConfigType {
@ -52,11 +56,8 @@ export type CaseStatusWithAllStatus = CaseStatuses | StatusAllType;
*/
export type CaseViewRefreshPropInterface = null | {
/**
* Refreshes the all of the user actions/comments in the view's timeline
* (note: this also triggers a silent `refreshCase()`)
* Refreshes the case its metrics and user actions/comments in the view's timeline
*/
refreshUserActionsAndComments: () => Promise<void>;
/** Refreshes the Case information only */
refreshCase: () => Promise<void>;
};
@ -162,6 +163,14 @@ export interface AllCases extends CasesStatus {
total: number;
}
export type CaseMetrics = CaseMetricsResponse;
export type CaseMetricsFeature =
| 'alerts.count'
| 'alerts.users'
| 'alerts.hosts'
| 'connectors'
| 'lifespan';
export enum SortFieldCase {
createdAt = 'createdAt',
closedAt = 'closedAt',

View file

@ -11,7 +11,7 @@ import { euiDarkVars } from '@kbn/ui-shared-deps-src/theme';
import { I18nProvider } from '@kbn/i18n-react';
import { ThemeProvider } from 'styled-components';
import { DEFAULT_FEATURES, SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { CasesContextValue } from '../../../common/ui/types';
import { CasesFeatures } from '../../../common/ui/types';
import { CasesProvider } from '../../components/cases_context';
import { createKibanaContextProviderMock } from '../lib/kibana/kibana_react.mock';
import { FieldHook } from '../shared_imports';
@ -19,7 +19,7 @@ import { FieldHook } from '../shared_imports';
interface Props {
children: React.ReactNode;
userCanCrud?: boolean;
features?: CasesContextValue['features'];
features?: CasesFeatures;
}
window.scrollTo = jest.fn();

View file

@ -25,7 +25,7 @@ import { CaseService } from '../../containers/use_get_case_user_actions';
import { StatusContextMenu } from './status_context_menu';
import { getStatusDate, getStatusTitle } from './helpers';
import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch';
import { OnUpdateFields } from '../case_view';
import type { OnUpdateFields } from '../case_view/types';
import { useCasesFeatures } from '../cases_context/use_cases_features';
const MyDescriptionList = styled(EuiDescriptionList)`

View file

@ -0,0 +1,82 @@
/*
* 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 { render } from '@testing-library/react';
import { basicCaseMetrics, basicCaseMetricsFeatures } from '../../containers/mock';
import { CaseViewMetrics } from './case_view_metrics';
import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui';
import { TestProviders } from '../../common/mock';
const renderCaseMetrics = ({
metrics = basicCaseMetrics,
features = basicCaseMetricsFeatures,
isLoading = false,
}: {
metrics?: CaseMetrics;
features?: CaseMetricsFeature[];
isLoading?: boolean;
} = {}) => {
return render(
<TestProviders>
<CaseViewMetrics metrics={metrics} isLoading={isLoading} features={features} />
</TestProviders>
);
};
const metricsFeaturesTests: Array<[CaseMetricsFeature, string, number]> = [
['alerts.count', 'Total Alerts', basicCaseMetrics.alerts!.count!],
['alerts.users', 'Associated Users', basicCaseMetrics.alerts!.users!.total!],
['alerts.hosts', 'Associated Hosts', basicCaseMetrics.alerts!.hosts!.total!],
['connectors', 'Total Connectors', basicCaseMetrics.connectors!.total!],
];
describe('CaseViewMetrics', () => {
it('should render', () => {
const { getByTestId } = renderCaseMetrics();
expect(getByTestId('case-view-metrics-panel')).toBeInTheDocument();
});
it('should render loading spinner', () => {
const { getByTestId } = renderCaseMetrics({ isLoading: true });
expect(getByTestId('case-view-metrics-spinner')).toBeInTheDocument();
});
it('should render metrics', () => {
const { getByText } = renderCaseMetrics();
expect(getByText('Total Alerts')).toBeInTheDocument();
expect(getByText('Associated Users')).toBeInTheDocument();
expect(getByText('Associated Hosts')).toBeInTheDocument();
expect(getByText('Total Connectors')).toBeInTheDocument();
});
it('should render metrics with default value 0', () => {
const { getByText, getAllByText } = renderCaseMetrics({ metrics: {} });
expect(getByText('Total Alerts')).toBeInTheDocument();
expect(getByText('Associated Users')).toBeInTheDocument();
expect(getByText('Associated Hosts')).toBeInTheDocument();
expect(getByText('Total Connectors')).toBeInTheDocument();
expect(getAllByText('0')).toHaveLength(basicCaseMetricsFeatures.length);
});
describe.each(metricsFeaturesTests)('Metrics feature: %s ', (feature, text, total) => {
it('should render metric', () => {
const { getByText } = renderCaseMetrics({ features: [feature] });
expect(getByText(text)).toBeInTheDocument();
expect(getByText(total)).toBeInTheDocument();
});
it('should not render other metrics', () => {
const { queryByText } = renderCaseMetrics({ features: [feature] });
metricsFeaturesTests.forEach(([_, otherMetricText]) => {
if (otherMetricText !== text) {
expect(queryByText(otherMetricText)).toBeNull();
}
});
});
});
});

View file

@ -0,0 +1,100 @@
/*
* 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, { useMemo } from 'react';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import { CaseMetrics, CaseMetricsFeature } from '../../../common/ui';
import {
ASSOCIATED_HOSTS_METRIC,
ASSOCIATED_USERS_METRIC,
TOTAL_ALERTS_METRIC,
TOTAL_CONNECTORS_METRIC,
} from './translations';
const MetricValue = styled(EuiFlexItem)`
font-size: ${({ theme }) => theme.eui.euiSizeL};
font-weight: bold;
`;
export interface CaseViewMetricsProps {
metrics: CaseMetrics | null;
features: CaseMetricsFeature[];
isLoading: boolean;
}
interface MetricItem {
title: string;
value: number;
}
type MetricItems = MetricItem[];
const useMetricItems = (
metrics: CaseMetrics | null,
features: CaseMetricsFeature[]
): MetricItems => {
const { alerts, connectors } = metrics ?? {};
const totalConnectors = connectors?.total ?? 0;
const alertsCount = alerts?.count ?? 0;
const totalAlertUsers = alerts?.users?.total ?? 0;
const totalAlertHosts = alerts?.hosts?.total ?? 0;
const metricItems = useMemo<MetricItems>(() => {
const items: Array<[CaseMetricsFeature, MetricItem]> = [
['alerts.count', { title: TOTAL_ALERTS_METRIC, value: alertsCount }],
['alerts.users', { title: ASSOCIATED_USERS_METRIC, value: totalAlertUsers }],
['alerts.hosts', { title: ASSOCIATED_HOSTS_METRIC, value: totalAlertHosts }],
['connectors', { title: TOTAL_CONNECTORS_METRIC, value: totalConnectors }],
];
return items.reduce(
(result: MetricItems, [feature, item]) => [
...result,
...(features.includes(feature) ? [item] : []),
],
[]
);
}, [features, alertsCount, totalAlertUsers, totalAlertHosts, totalConnectors]);
return metricItems;
};
const CaseViewMetricItems: React.FC<{ metricItems: MetricItems }> = React.memo(
({ metricItems }) => (
<>
{metricItems.map(({ title, value }, index) => (
<EuiFlexItem key={index}>
<EuiFlexGroup direction="column" gutterSize="s" responsive={false}>
<EuiFlexItem>{title}</EuiFlexItem>
<MetricValue>{value}</MetricValue>
</EuiFlexGroup>
</EuiFlexItem>
))}
</>
)
);
CaseViewMetricItems.displayName = 'CaseViewMetricItems';
export const CaseViewMetrics: React.FC<CaseViewMetricsProps> = React.memo(
({ metrics, features, isLoading }) => {
const metricItems = useMetricItems(metrics, features);
return (
<EuiPanel data-test-subj="case-view-metrics-panel" hasShadow={false} hasBorder={true}>
<EuiFlexGroup gutterSize="xl" wrap={true} responsive={false}>
{isLoading ? (
<EuiFlexItem>
<EuiLoadingSpinner data-test-subj="case-view-metrics-spinner" size="l" />
</EuiFlexItem>
) : (
<CaseViewMetricItems metricItems={metricItems} />
)}
</EuiFlexGroup>
</EuiPanel>
);
}
);
CaseViewMetrics.displayName = 'CaseViewMetrics';

View file

@ -0,0 +1,650 @@
/*
* 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 { mount } from 'enzyme';
import { waitFor } from '@testing-library/react';
import '../../common/mock/match_media';
import { CaseViewPage } from './case_view_page';
import { CaseViewPageProps } from './types';
import {
basicCaseClosed,
basicCaseMetrics,
caseUserActions,
getAlertUserAction,
} from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useGetCaseMetrics } from '../../containers/use_get_case_metrics';
import { CaseType, ConnectorTypes } from '../../../common/api';
import { caseViewProps, caseData } from './index.test';
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_metrics');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../user_action_tree/user_action_timestamp');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock;
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
export const caseProps: CaseViewPageProps = {
...caseViewProps,
caseId: caseData.id,
caseData,
fetchCase: jest.fn(),
updateCase: jest.fn(),
};
export const caseClosedProps: CaseViewPageProps = {
...caseProps,
caseData: basicCaseClosed,
};
describe('CaseViewPage', () => {
const updateCaseProperty = jest.fn();
const fetchCaseUserActions = jest.fn();
const pushCaseToExternalService = jest.fn();
const fetchCaseMetrics = jest.fn();
const data = caseProps.caseData;
const defaultUpdateCaseState = {
isLoading: false,
isError: false,
updateKey: null,
updateCaseProperty,
};
const defaultUseGetCaseUserActions = {
caseUserActions: [...caseUserActions, getAlertUserAction()],
caseServices: {},
fetchCaseUserActions,
firstIndexPushToService: -1,
hasDataToPush: false,
isLoading: false,
isError: false,
lastIndexPushToService: -1,
participants: [data.createdBy],
};
const defaultGetCaseMetrics = {
isLoading: false,
isError: false,
metrics: basicCaseMetrics,
fetchCaseMetrics,
};
beforeEach(() => {
jest.clearAllMocks();
useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState);
useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics);
useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService });
useConnectorsMock.mockReturnValue({ connectors: connectorsMock, loading: false });
});
it('should render CaseViewPage', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual(
data.title
);
});
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Open'
);
expect(wrapper.find(`[data-test-subj="case-view-metrics"]`).exists()).toBeFalsy();
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`)
.first()
.text()
).toEqual(data.tags[0]);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`)
.first()
.text()
).toEqual(data.tags[1]);
expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual(
data.createdBy.username
);
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(data.createdAt);
expect(
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
.first()
.text()
).toBe(data.description);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text()
).toBe('Mark in progress');
});
it('should render CaseViewPage with metrics', async () => {
const wrapper = mount(
<TestProviders features={{ metrics: ['alerts.count'] }}>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="case-view-metrics"]`).exists()).toBeTruthy();
});
});
it('should show closed indicators in header when case is closed', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
caseData: basicCaseClosed,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseClosedProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(basicCaseClosed.closedAt);
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Closed'
);
});
});
it('should update status', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click');
wrapper
.find('button[data-test-subj="case-view-status-dropdown-closed"]')
.first()
.simulate('click');
await waitFor(() => {
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
expect(updateObject.updateKey).toEqual('status');
expect(updateObject.updateValue).toEqual('closed');
});
});
it('should display EditableTitle isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'title',
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="editable-title-loading"]').first().exists()
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()
).toBeFalsy();
});
});
it('should display description isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'description',
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find(
'[data-test-subj="description-action"] [data-test-subj="user-action-title-loading"]'
)
.first()
.exists()
).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="description-action"] [data-test-subj="property-actions"]')
.first()
.exists()
).toBeFalsy();
});
});
it('should display tags isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'tags',
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find('[data-test-subj="case-view-tag-list"] [data-test-subj="tag-list-loading"]')
.first()
.exists()
).toBeTruthy();
expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy();
});
});
it('should update title', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
const newTitle = 'The new title';
wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click');
wrapper
.find(`[data-test-subj="editable-title-input-field"]`)
.last()
.simulate('change', { target: { value: newTitle } });
wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click');
const updateObject = updateCaseProperty.mock.calls[0][0];
await waitFor(() => {
expect(updateObject.updateKey).toEqual('title');
expect(updateObject.updateValue).toEqual(newTitle);
});
});
it('should push updates on button click', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists()
).toBeTruthy();
});
wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click');
await waitFor(() => {
expect(pushCaseToExternalService).toHaveBeenCalled();
});
});
it('should disable the push button when connector is invalid', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...{
...caseProps,
caseData: { ...caseProps.caseData, connectorId: 'not-exist' },
}}
/>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('button[data-test-subj="push-to-external-service"]').first().prop('disabled')
).toBeTruthy();
});
});
// TODO: fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
onError();
});
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...caseProps}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}
/>
</TestProviders>
);
const connectorName = wrapper
.find('[data-test-subj="settings-connector-card"] .euiTitle')
.first()
.text();
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(
wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text()
).toBe(connectorName);
});
});
it('should update connector', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...caseProps}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy();
});
wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click');
await waitFor(() => {
wrapper.update();
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(updateObject.updateValue).toEqual({
id: 'resilient-2',
name: 'My Connector 2',
type: ConnectorTypes.resilient,
fields: {
incidentTypes: null,
severityCode: null,
},
});
});
});
it('it should call onComponentInitialized on mount', async () => {
const onComponentInitialized = jest.fn();
mount(
<TestProviders>
<CaseViewPage {...caseProps} onComponentInitialized={onComponentInitialized} />
</TestProviders>
);
await waitFor(() => {
expect(onComponentInitialized).toHaveBeenCalled();
});
});
it('should show loading content when loading alerts', async () => {
const useFetchAlertData = jest.fn().mockReturnValue([true]);
useGetCaseUserActionsMock.mockReturnValue({
caseServices: {},
caseUserActions: [],
hasDataToPush: false,
isError: false,
isLoading: true,
participants: [],
});
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} useFetchAlertData={useFetchAlertData} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="case-view-loading-content"]').first().exists()
).toBeTruthy();
expect(wrapper.find('[data-test-subj="user-actions"]').first().exists()).toBeFalsy();
});
});
it('should call show alert details with expected arguments', async () => {
const showAlertDetails = jest.fn();
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} showAlertDetails={showAlertDetails} />
</TestProviders>
);
wrapper
.find('[data-test-subj="comment-action-show-alert-alert-action-id"] button')
.first()
.simulate('click');
await waitFor(() => {
expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1');
});
});
it('should show the rule name', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find(
'[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent'
)
.first()
.text()
).toBe('added an alert from Awesome rule');
});
});
it('should update settings', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click');
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('settings');
expect(updateObject.updateValue).toEqual({ syncAlerts: false });
});
});
it('should show the correct connector name on the push button', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...{ ...caseProps, connector: { ...caseProps, name: 'old-name' } }} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find('[data-test-subj="has-data-to-push-button"]')
.first()
.text()
.includes('My Connector 2')
).toBe(true);
});
});
describe('Callouts', () => {
it('it shows the danger callout when a connector has been deleted', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: false }));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeTruthy();
});
});
it('it does NOT shows the danger callout when connectors are loading', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: true }));
const wrapper = mount(
<TestProviders>
<CaseViewPage {...caseProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeFalsy();
});
});
});
describe('Collections', () => {
it('it does not allow the user to update the status', async () => {
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true);
expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists()
).toBe(false);
});
});
it('it shows the push button when has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true);
});
});
it('it does not show the horizontal rule when does NOT has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: false,
}));
const wrapper = mount(
<TestProviders>
<CaseViewPage
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists()
).toBe(false);
});
});
});
});

View file

@ -0,0 +1,446 @@
/*
* 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, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLoadingContent,
EuiText,
} from '@elastic/eui';
import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../common/api';
import { Case, UpdateKey, UpdateByKey } from '../../../common/ui';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { getTypedPayload } from '../../containers/utils';
import { ContentWrapper, WhitePageWrapper } from '../wrappers';
import { CaseActionBar } from '../case_action_bar';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { EditConnector } from '../edit_connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils';
import { StatusActionButton } from '../status/button';
import * as i18n from './translations';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { getConnectorById } from '../utils';
import { useCasesContext } from '../cases_context/use_cases_context';
import { useCaseViewNavigation } from '../../common/navigation';
import { HeaderPage } from '../header_page';
import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
import { useGetCaseMetrics } from '../../containers/use_get_case_metrics';
import { CaseViewMetrics } from './case_view_metrics';
import type { CaseViewPageProps, OnUpdateFields } from './types';
import { useCasesFeatures } from '../cases_context/use_cases_features';
const useOnUpdateField = ({
caseData,
caseId,
subCaseId,
handleUpdateField,
}: {
caseData: Case;
caseId: string;
subCaseId?: string;
handleUpdateField: (newCase: Case, updateKey: UpdateKey) => void;
}) => {
const {
isLoading,
updateKey: loadingKey,
updateCaseProperty,
} = useUpdateCase({ caseId, subCaseId });
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
const callUpdate = (updateKey: UpdateKey, updateValue: UpdateByKey['updateValue']) =>
updateCaseProperty({
updateKey,
updateValue,
updateCase: (newCase) => handleUpdateField(newCase, updateKey),
caseData,
onSuccess,
onError,
});
switch (key) {
case 'title':
const titleUpdate = getTypedPayload<string>(value);
if (titleUpdate.length > 0) {
callUpdate('title', titleUpdate);
}
break;
case 'connector':
const connector = getTypedPayload<CaseConnector>(value);
if (connector != null) {
callUpdate('connector', connector);
}
break;
case 'description':
const descriptionUpdate = getTypedPayload<string>(value);
if (descriptionUpdate.length > 0) {
callUpdate('description', descriptionUpdate);
}
break;
case 'tags':
const tagsUpdate = getTypedPayload<string[]>(value);
callUpdate('tags', tagsUpdate);
break;
case 'status':
const statusUpdate = getTypedPayload<CaseStatuses>(value);
if (caseData.status !== value) {
callUpdate('status', statusUpdate);
}
break;
case 'settings':
const settingsUpdate = getTypedPayload<CaseAttributes['settings']>(value);
if (caseData.settings !== value) {
callUpdate('settings', settingsUpdate);
}
break;
default:
return null;
}
},
[updateCaseProperty, handleUpdateField, caseData]
);
return { onUpdateField, isLoading, loadingKey };
};
export const CaseViewPage = React.memo<CaseViewPageProps>(
({
caseData,
caseId,
fetchCase,
onComponentInitialized,
actionsNavigation,
ruleDetailsNavigation,
showAlertDetails,
subCaseId,
updateCase,
useFetchAlertData,
refreshRef,
}) => {
const { userCanCrud } = useCasesContext();
const { metricsFeatures } = useCasesFeatures();
const { getCaseViewUrl } = useCaseViewNavigation();
useCasesTitleBreadcrumbs(caseData.title);
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
const timelineUi = useTimelineContext()?.ui;
const {
caseUserActions,
fetchCaseUserActions,
caseServices,
hasDataToPush,
isLoading: isLoadingUserActions,
participants,
} = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId);
const refetchCaseUserActions = useCallback(() => {
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId);
}, [caseId, fetchCaseUserActions, subCaseId, caseData]);
const {
metrics,
isLoading: isLoadingMetrics,
fetchCaseMetrics,
} = useGetCaseMetrics(caseId, metricsFeatures);
const handleRefresh = useCallback(() => {
fetchCase();
fetchCaseMetrics();
refetchCaseUserActions();
}, [fetchCase, refetchCaseUserActions, fetchCaseMetrics]);
const handleUpdateCase = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchCaseUserActions(caseId, newCase.connector.id, subCaseId);
fetchCaseMetrics();
},
[updateCase, fetchCaseUserActions, caseId, subCaseId, fetchCaseMetrics]
);
const handleUpdateField = useCallback(
(newCase: Case, updateKey: UpdateKey) => {
updateCase({ ...newCase, comments: caseData.comments });
fetchCaseUserActions(caseId, newCase.connector.id, subCaseId);
fetchCaseMetrics();
},
[updateCase, caseData, fetchCaseUserActions, caseId, subCaseId, fetchCaseMetrics]
);
const { onUpdateField, isLoading, loadingKey } = useOnUpdateField({
caseId,
subCaseId,
caseData,
handleUpdateField,
});
// Set `refreshRef` if needed
useEffect(() => {
let isStale = false;
if (refreshRef) {
refreshRef.current = {
refreshCase: async () => {
// Do nothing if component (or instance of this render cycle) is stale or it is already loading
if (isStale || isLoading || isLoadingMetrics || isLoadingUserActions) {
return;
}
await Promise.all([fetchCase(true), fetchCaseMetrics(true), refetchCaseUserActions()]);
},
};
return () => {
isStale = true;
refreshRef.current = null;
};
}
}, [
fetchCase,
fetchCaseMetrics,
refetchCaseUserActions,
isLoadingUserActions,
isLoadingMetrics,
isLoading,
refreshRef,
updateCase,
]);
const {
loading: isLoadingConnectors,
connectors,
permissionsError,
} = useConnectors({
toastPermissionsErrors: false,
});
const [connectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connector.id);
return [connector?.name ?? '', !!connector];
}, [connectors, caseData.connector]);
const currentExternalIncident = useMemo(
() =>
caseServices != null && caseServices[caseData.connector.id] != null
? caseServices[caseData.connector.id]
: null,
[caseServices, caseData.connector]
);
const onSubmitConnector = useCallback(
(connectorId, connectorFields, onError, onSuccess) => {
const connector = getConnectorById(connectorId, connectors);
const connectorToUpdate = connector
? normalizeActionConnector(connector)
: getNoneConnector();
onUpdateField({
key: 'connector',
value: { ...connectorToUpdate, fields: connectorFields },
onSuccess,
onError,
});
},
[onUpdateField, connectors]
);
const onSubmitTags = useCallback(
(newTags) => onUpdateField({ key: 'tags', value: newTags }),
[onUpdateField]
);
const onSubmitTitle = useCallback(
(newTitle) =>
onUpdateField({
key: 'title',
value: newTitle,
}),
[onUpdateField]
);
const changeStatus = useCallback(
(status: CaseStatuses) =>
onUpdateField({
key: 'status',
value: status,
}),
[onUpdateField]
);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(getCaseViewUrl({ detailName: caseId, subCaseId })),
}),
[caseData.title, getCaseViewUrl, caseId, subCaseId]
);
useEffect(() => {
if (initLoadingData && !isLoadingUserActions) {
setInitLoadingData(false);
}
}, [initLoadingData, isLoadingUserActions]);
const onShowAlertDetails = useCallback(
(alertId: string, index: string) => {
if (showAlertDetails) {
showAlertDetails(alertId, index);
}
},
[showAlertDetails]
);
// useEffect used for component's initialization
useEffect(() => {
if (init.current) {
init.current = false;
if (onComponentInitialized) {
onComponentInitialized();
}
}
}, [onComponentInitialized]);
return (
<>
<HeaderPage
showBackButton={true}
data-test-subj="case-view-title"
titleNode={
<EditableTitle
userCanCrud={userCanCrud}
isLoading={isLoading && loadingKey === 'title'}
title={caseData.title}
onSubmit={onSubmitTitle}
/>
}
title={caseData.title}
>
<CaseActionBar
caseData={caseData}
currentExternalIncident={currentExternalIncident}
userCanCrud={userCanCrud}
isLoading={isLoading && (loadingKey === 'status' || loadingKey === 'settings')}
onRefresh={handleRefresh}
onUpdateField={onUpdateField}
/>
</HeaderPage>
<WhitePageWrapper>
<ContentWrapper>
<EuiFlexGroup>
<EuiFlexItem grow={6}>
{initLoadingData && (
<EuiLoadingContent lines={8} data-test-subj="case-view-loading-content" />
)}
{!initLoadingData && (
<EuiFlexGroup direction="column" responsive={false}>
{metricsFeatures.length > 0 && (
<>
<EuiFlexItem>
<CaseViewMetrics
data-test-subj="case-view-metrics"
isLoading={isLoadingMetrics}
metrics={metrics}
features={metricsFeatures}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiText>
<h4>{i18n.ACTIVITY}</h4>
<EuiHorizontalRule margin="xs" />
</EuiText>
</EuiFlexItem>
</>
)}
<EuiFlexItem>
<UserActionTree
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseServices={caseServices}
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
actionsNavigation={actionsNavigation}
fetchUserActions={refetchCaseUserActions}
isLoadingDescription={isLoading && loadingKey === 'description'}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
renderInvestigateInTimelineActionComponent={
timelineUi?.renderInvestigateInTimelineActionComponent
}
statusActionButton={
caseData.type !== CaseType.collection && userCanCrud ? (
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
isLoading={isLoading && loadingKey === 'status'}
/>
) : null
}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<UserList
data-test-subj="case-view-user-list-reporter"
email={emailContent}
headline={i18n.REPORTER}
users={[caseData.createdBy]}
/>
<UserList
data-test-subj="case-view-user-list-participants"
email={emailContent}
headline={i18n.PARTICIPANTS}
loading={isLoadingUserActions}
users={participants}
/>
<TagList
data-test-subj="case-view-tag-list"
userCanCrud={userCanCrud}
tags={caseData.tags}
onSubmit={onSubmitTags}
isLoading={isLoading && loadingKey === 'tags'}
/>
<EditConnector
caseData={caseData}
caseServices={caseServices}
connectorName={connectorName}
connectors={connectors}
hasDataToPush={hasDataToPush && userCanCrud}
hideConnectorServiceNowSir={
subCaseId != null || caseData.type === CaseType.collection
}
isLoading={isLoadingConnectors || (isLoading && loadingKey === 'connector')}
isValidConnector={isLoadingConnectors ? true : isValidConnector}
onSubmit={onSubmitConnector}
permissionsError={permissionsError}
updateCase={handleUpdateCase}
userActions={caseUserActions}
userCanCrud={userCanCrud}
/>
</EuiFlexItem>
</EuiFlexGroup>
</ContentWrapper>
</WhitePageWrapper>
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
</>
);
}
);
CaseViewPage.displayName = 'CaseViewPage';

View file

@ -7,7 +7,7 @@
import { isEmpty } from 'lodash';
import { CommentType } from '../../../common/api';
import { Comment } from '../../containers/types';
import type { Comment } from '../../containers/types';
export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => {
const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => {

View file

@ -10,35 +10,41 @@ import { mount } from 'enzyme';
import { act, waitFor } from '@testing-library/react';
import '../../common/mock/match_media';
import { CaseComponent, CaseComponentProps, CaseView, CaseViewProps } from '.';
import { CaseView } from '.';
import { CaseViewProps } from './types';
import {
basicCase,
basicCaseClosed,
caseUserActions,
alertComment,
getAlertUserAction,
basicCaseMetrics,
} from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { SpacesApi } from '../../../../spaces/public';
import { useUpdateCase } from '../../containers/use_update_case';
import { useGetCase } from '../../containers/use_get_case';
import { UseGetCase, useGetCase } from '../../containers/use_get_case';
import { useGetCaseMetrics } from '../../containers/use_get_case_metrics';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { CaseType, ConnectorTypes } from '../../../common/api';
import { ConnectorTypes } from '../../../common/api';
import { Case } from '../../../common/ui';
import { useKibana } from '../../common/lib/kibana';
jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/use_get_case_metrics');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../user_action_tree/user_action_timestamp');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
const useGetCaseMock = useGetCase as jest.Mock;
const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock;
const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
@ -79,8 +85,7 @@ const alertsHit = [
},
];
export const caseProps: CaseComponentProps = {
caseId: basicCase.id,
export const caseViewProps: CaseViewProps = {
onComponentInitialized: jest.fn(),
actionsNavigation: {
href: jest.fn(),
@ -98,42 +103,43 @@ export const caseProps: CaseComponentProps = {
'alert-id-2': alertsHit[1],
},
],
caseData: {
...basicCase,
comments: [...basicCase.comments, alertComment],
connector: {
id: 'resilient-2',
name: 'Resilient',
type: ConnectorTypes.resilient,
fields: null,
},
};
export const caseData: Case = {
...basicCase,
comments: [...basicCase.comments, alertComment],
connector: {
id: 'resilient-2',
name: 'Resilient',
type: ConnectorTypes.resilient,
fields: null,
},
fetchCase: jest.fn(),
updateCase: jest.fn(),
};
export const caseClosedProps: CaseComponentProps = {
...caseProps,
caseData: basicCaseClosed,
};
describe('CaseView ', () => {
describe('CaseView', () => {
const updateCaseProperty = jest.fn();
const fetchCaseUserActions = jest.fn();
const fetchCase = jest.fn();
const fetchCaseMetrics = jest.fn();
const updateCase = jest.fn();
const pushCaseToExternalService = jest.fn();
const data = caseProps.caseData;
const defaultGetCase = {
isLoading: false,
isError: false,
data,
data: caseData,
resolveOutcome: 'exactMatch',
updateCase,
fetchCase,
};
const defaultGetCaseMetrics = {
isLoading: false,
isError: false,
metrics: basicCaseMetrics,
fetchCaseMetrics,
};
const defaultUpdateCaseState = {
isLoading: false,
isError: false,
@ -150,245 +156,32 @@ describe('CaseView ', () => {
isLoading: false,
isError: false,
lastIndexPushToService: -1,
participants: [data.createdBy],
participants: [caseData.createdBy],
};
beforeEach(() => {
jest.clearAllMocks();
useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState);
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockImplementation(() => ({
isLoading: false,
pushCaseToExternalService,
}));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({
actionTypeTitle: '.servicenow',
iconClass: 'logoSecurity',
});
const mockGetCase = (props: Partial<UseGetCase> = {}) => {
useGetCaseMock.mockReturnValue({ ...defaultGetCase, ...props });
};
beforeAll(() => {
mockGetCase();
useGetCaseMetricsMock.mockReturnValue(defaultGetCaseMetrics);
useUpdateCaseMock.mockReturnValue(defaultUpdateCaseState);
useGetCaseUserActionsMock.mockReturnValue(defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockReturnValue({ isLoading: false, pushCaseToExternalService });
useConnectorsMock.mockReturnValue({ connectors: connectorsMock, loading: false });
useKibanaMock().services.spaces = { ui: spacesUiApiMock } as unknown as SpacesApi;
});
it('should render CaseComponent', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find(`[data-test-subj="case-view-title"]`).first().prop('title')).toEqual(
data.title
);
});
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Open'
);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-coke"]`)
.first()
.text()
).toEqual(data.tags[0]);
expect(
wrapper
.find(`[data-test-subj="case-view-tag-list"] [data-test-subj="tag-pepsi"]`)
.first()
.text()
).toEqual(data.tags[1]);
expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual(
data.createdBy.username
);
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(data.createdAt);
expect(
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]`)
.first()
.text()
).toBe(data.description);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text()
).toBe('Mark in progress');
});
it('should show closed indicators in header when case is closed', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
caseData: basicCaseClosed,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseClosedProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(basicCaseClosed.closedAt);
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Closed'
);
});
});
it('should update status', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click');
wrapper
.find('button[data-test-subj="case-view-status-dropdown-closed"]')
.first()
.simulate('click');
await waitFor(() => {
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
expect(updateObject.updateKey).toEqual('status');
expect(updateObject.updateValue).toEqual('closed');
});
});
it('should display EditableTitle isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'title',
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="editable-title-loading"]').first().exists()
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="editable-title-edit-icon"]').first().exists()
).toBeFalsy();
});
});
it('should display description isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'description',
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find(
'[data-test-subj="description-action"] [data-test-subj="user-action-title-loading"]'
)
.first()
.exists()
).toBeTruthy();
expect(
wrapper
.find('[data-test-subj="description-action"] [data-test-subj="property-actions"]')
.first()
.exists()
).toBeFalsy();
});
});
it('should display tags isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'tags',
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find('[data-test-subj="case-view-tag-list"] [data-test-subj="tag-list-loading"]')
.first()
.exists()
).toBeTruthy();
expect(wrapper.find('button[data-test-subj="tag-list-edit"]').first().exists()).toBeFalsy();
});
});
it('should update title', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
const newTitle = 'The new title';
wrapper.find(`[data-test-subj="editable-title-edit-icon"]`).first().simulate('click');
wrapper
.find(`[data-test-subj="editable-title-input-field"]`)
.last()
.simulate('change', { target: { value: newTitle } });
wrapper.find(`[data-test-subj="editable-title-submit-btn"]`).first().simulate('click');
const updateObject = updateCaseProperty.mock.calls[0][0];
await waitFor(() => {
expect(updateObject.updateKey).toEqual('title');
expect(updateObject.updateValue).toEqual(newTitle);
});
});
it('should push updates on button click', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...{ ...caseProps, updateCase }} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="has-data-to-push-button"]').first().exists()
).toBeTruthy();
});
wrapper.find('[data-test-subj="push-to-external-service"]').first().simulate('click');
await waitFor(() => {
expect(pushCaseToExternalService).toHaveBeenCalled();
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should return null if error', async () => {
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
isError: true,
}));
mockGetCase({ isError: true });
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
await waitFor(() => {
@ -397,13 +190,10 @@ describe('CaseView ', () => {
});
it('should return spinner if loading', async () => {
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
isLoading: true,
}));
mockGetCase({ isLoading: true });
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
await waitFor(() => {
@ -412,13 +202,10 @@ describe('CaseView ', () => {
});
it('should return case view when data is there', async () => {
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'exactMatch',
}));
mockGetCase({ resolveOutcome: 'exactMatch' });
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
await waitFor(() => {
@ -430,14 +217,10 @@ describe('CaseView ', () => {
it('should redirect case view when resolves to alias match', async () => {
const resolveAliasId = `${defaultGetCase.data.id}_2`;
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'aliasMatch',
resolveAliasId,
}));
mockGetCase({ resolveOutcome: 'aliasMatch', resolveAliasId });
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
await waitFor(() => {
@ -452,14 +235,10 @@ describe('CaseView ', () => {
it('should redirect case view when resolves to conflict', async () => {
const resolveAliasId = `${defaultGetCase.data.id}_2`;
(useGetCase as jest.Mock).mockImplementation(() => ({
...defaultGetCase,
resolveOutcome: 'conflict',
resolveAliasId,
}));
mockGetCase({ resolveOutcome: 'conflict', resolveAliasId });
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
await waitFor(() => {
@ -479,245 +258,17 @@ describe('CaseView ', () => {
(useGetCase as jest.Mock).mockImplementation(() => defaultGetCase);
const wrapper = mount(
<TestProviders>
<CaseView {...caseProps} />
<CaseView {...caseViewProps} />
</TestProviders>
);
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
await waitFor(() => {
expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id, 'resilient-2', undefined);
expect(fetchCaseUserActions).toBeCalledWith(caseData.id, 'resilient-2', undefined);
expect(fetchCaseMetrics).toBeCalled();
expect(fetchCase).toBeCalled();
});
});
it('should disable the push button when connector is invalid', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent
{...{
...caseProps,
updateCase,
caseData: { ...caseProps.caseData, connectorId: 'not-exist' },
}}
/>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('button[data-test-subj="push-to-external-service"]').first().prop('disabled')
).toBeTruthy();
});
});
// TODO: fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
onError();
});
const wrapper = mount(
<TestProviders>
<CaseComponent
{...caseProps}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}
/>
</TestProviders>
);
const connectorName = wrapper
.find('[data-test-subj="settings-connector-card"] .euiTitle')
.first()
.text();
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => wrapper.update());
wrapper.find(`[data-test-subj="edit-connectors-submit"]`).last().simulate('click');
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(
wrapper.find('[data-test-subj="settings-connector-card"] .euiTitle').first().text()
).toBe(connectorName);
});
});
it('should update connector', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent
{...caseProps}
caseData={{
...caseProps.caseData,
connector: {
id: 'servicenow-1',
name: 'SN 1',
type: ConnectorTypes.serviceNowITSM,
fields: null,
},
}}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="connector-edit"] button').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find('button[data-test-subj="dropdown-connector-resilient-2"]').simulate('click');
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy();
});
wrapper.find(`button[data-test-subj="edit-connectors-submit"]`).first().simulate('click');
await waitFor(() => {
wrapper.update();
expect(updateCaseProperty).toHaveBeenCalledTimes(1);
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('connector');
expect(updateObject.updateValue).toEqual({
id: 'resilient-2',
name: 'My Connector 2',
type: ConnectorTypes.resilient,
fields: {
incidentTypes: null,
severityCode: null,
},
});
});
});
it('it should call onComponentInitialized on mount', async () => {
const onComponentInitialized = jest.fn();
mount(
<TestProviders>
<CaseComponent {...caseProps} onComponentInitialized={onComponentInitialized} />
</TestProviders>
);
await waitFor(() => {
expect(onComponentInitialized).toHaveBeenCalled();
});
});
it('should show loading content when loading alerts', async () => {
const useFetchAlertData = jest.fn().mockReturnValue([true]);
useGetCaseUserActionsMock.mockReturnValue({
caseServices: {},
caseUserActions: [],
hasDataToPush: false,
isError: false,
isLoading: true,
participants: [],
});
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} useFetchAlertData={useFetchAlertData} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="case-view-loading-content"]').first().exists()
).toBeTruthy();
expect(wrapper.find('[data-test-subj="user-actions"]').first().exists()).toBeFalsy();
});
});
it('should call show alert details with expected arguments', async () => {
const showAlertDetails = jest.fn();
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} showAlertDetails={showAlertDetails} />
</TestProviders>
);
wrapper
.find('[data-test-subj="comment-action-show-alert-alert-action-id"] button')
.first()
.simulate('click');
await waitFor(() => {
expect(showAlertDetails).toHaveBeenCalledWith('alert-id-1', 'alert-index-1');
});
});
it('should show the rule name', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find(
'[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent'
)
.first()
.text()
).toBe('added an alert from Awesome rule');
});
});
it('should update settings', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
wrapper.find('button[data-test-subj="sync-alerts-switch"]').first().simulate('click');
await waitFor(() => {
wrapper.update();
const updateObject = updateCaseProperty.mock.calls[0][0];
expect(updateObject.updateKey).toEqual('settings');
expect(updateObject.updateValue).toEqual({ syncAlerts: false });
});
});
it('should show the correct connector name on the push button', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false }));
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent {...{ ...caseProps, connector: { ...caseProps, name: 'old-name' } }} />
</TestProviders>
);
await waitFor(() => {
expect(
wrapper
.find('[data-test-subj="has-data-to-push-button"]')
.first()
.text()
.includes('My Connector 2')
).toBe(true);
});
});
describe('when a `refreshRef` prop is provided', () => {
let refreshRef: CaseViewProps['refreshRef'];
@ -744,121 +295,18 @@ describe('CaseView ', () => {
});
it('should set it with expected refresh interface', async () => {
await waitFor(() => {
expect(refreshRef!.current).toEqual({
refreshUserActionsAndComments: expect.any(Function),
refreshCase: expect.any(Function),
});
expect(refreshRef!.current).toEqual({
refreshCase: expect.any(Function),
});
});
it('should refresh actions and comments', async () => {
refreshRef!.current!.refreshCase();
await waitFor(() => {
refreshRef!.current!.refreshUserActionsAndComments();
expect(fetchCaseUserActions).toBeCalledWith('basic-case-id', 'resilient-2', undefined);
expect(fetchCaseMetrics).toBeCalledWith(true);
expect(fetchCase).toBeCalledWith(true);
});
});
it('should refresh case', async () => {
await waitFor(() => {
refreshRef!.current!.refreshCase();
expect(fetchCase).toBeCalledWith(); // No args given to `fetchCase()`
});
});
});
describe('Callouts', () => {
it('it shows the danger callout when a connector has been deleted', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: false }));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeTruthy();
});
});
it('it does NOT shows the danger callout when connectors are loading', async () => {
useConnectorsMock.mockImplementation(() => ({ connectors: [], loading: true }));
const wrapper = mount(
<TestProviders>
<CaseComponent {...caseProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.update();
expect(wrapper.find('.euiCallOut--danger').first().exists()).toBeFalsy();
});
});
});
describe('Collections', () => {
it('it does not allow the user to update the status', async () => {
const wrapper = mount(
<TestProviders>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true);
expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists()
).toBe(false);
});
});
it('it shows the push button when has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true);
});
});
it('it does not show the horizontal rule when does NOT has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: false,
}));
const wrapper = mount(
<TestProviders>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists()
).toBe(false);
});
});
});
});

View file

@ -5,480 +5,24 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo, useState, useRef, MutableRefObject } from 'react';
import React, { useCallback, useEffect } from 'react';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingContent, EuiLoadingSpinner } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { Case, Ecs, CaseViewRefreshPropInterface } from '../../../common/ui/types';
import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../common/api';
import { HeaderPage } from '../header_page';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { UseGetCase, useGetCase } from '../../containers/use_get_case';
import { UserActionTree } from '../user_action_tree';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { getTypedPayload } from '../../containers/utils';
import { ContentWrapper, WhitePageWrapper } from '../wrappers';
import { CaseActionBar } from '../case_action_bar';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { EditConnector } from '../edit_connector';
import { useConnectors } from '../../containers/configure/use_connectors';
import { normalizeActionConnector, getNoneConnector } from '../configure_cases/utils';
import { StatusActionButton } from '../status/button';
import { useGetCase } from '../../containers/use_get_case';
import * as i18n from './translations';
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
import { useTimelineContext } from '../timeline_context/use_timeline_context';
import { CasesNavigation } from '../links';
import { getConnectorById } from '../utils';
import { CasesTimelineIntegrationProvider } from '../timeline_context';
import { DoesNotExist } from './does_not_exist';
import { useKibana } from '../../common/lib/kibana';
import { useCasesContext } from '../cases_context/use_cases_context';
import {
generateCaseViewPath,
useCaseViewNavigation,
useCaseViewParams,
} from '../../common/navigation';
import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs';
export interface CaseViewComponentProps {
caseId: string;
subCaseId?: string;
onComponentInitialized?: () => void;
actionsNavigation?: CasesNavigation<string, 'configurable'>;
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
showAlertDetails?: (alertId: string, index: string) => void;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
*/
refreshRef?: MutableRefObject<CaseViewRefreshPropInterface>;
}
export interface CaseViewProps extends Omit<CaseViewComponentProps, 'caseId' | 'subCaseId'> {
timelineIntegration?: CasesTimelineIntegration;
}
export interface OnUpdateFields {
key: keyof Case;
value: Case[keyof Case];
onSuccess?: () => void;
onError?: () => void;
}
import { generateCaseViewPath, useCaseViewParams } from '../../common/navigation';
import { CaseViewPage } from './case_view_page';
import type { CaseViewProps } from './types';
const MyEuiFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
export interface CaseComponentProps extends CaseViewComponentProps {
fetchCase: UseGetCase['fetchCase'];
caseData: Case;
updateCase: (newCase: Case) => void;
}
export const CaseComponent = React.memo<CaseComponentProps>(
({
caseData,
caseId,
fetchCase,
onComponentInitialized,
actionsNavigation,
ruleDetailsNavigation,
showAlertDetails,
subCaseId,
updateCase,
useFetchAlertData,
refreshRef,
}) => {
const { userCanCrud } = useCasesContext();
const { getCaseViewUrl } = useCaseViewNavigation();
useCasesTitleBreadcrumbs(caseData.title);
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);
const timelineUi = useTimelineContext()?.ui;
const {
caseUserActions,
fetchCaseUserActions,
caseServices,
hasDataToPush,
isLoading: isLoadingUserActions,
participants,
} = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId);
const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({
caseId,
subCaseId,
});
// Set `refreshRef` if needed
useEffect(() => {
let isStale = false;
if (refreshRef) {
refreshRef.current = {
refreshCase: async () => {
// Do nothing if component (or instance of this render cycle) is stale
if (isStale) {
return;
}
await fetchCase();
},
refreshUserActionsAndComments: async () => {
// Do nothing if component (or instance of this render cycle) is stale
// -- OR --
// it is already loading
if (isStale || isLoadingUserActions) {
return;
}
await Promise.all([
fetchCase(true),
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId),
]);
},
};
return () => {
isStale = true;
refreshRef.current = null;
};
}
}, [
caseData.connector.id,
caseId,
fetchCase,
fetchCaseUserActions,
isLoadingUserActions,
refreshRef,
subCaseId,
updateCase,
]);
// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
const handleUpdateNewCase = (newCase: Case) =>
updateCase({ ...newCase, comments: caseData.comments });
switch (key) {
case 'title':
const titleUpdate = getTypedPayload<string>(value);
if (titleUpdate.length > 0) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'title',
updateValue: titleUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'connector':
const connector = getTypedPayload<CaseConnector>(value);
if (connector != null) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'connector',
updateValue: connector,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'description':
const descriptionUpdate = getTypedPayload<string>(value);
if (descriptionUpdate.length > 0) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'description',
updateValue: descriptionUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'tags':
const tagsUpdate = getTypedPayload<string[]>(value);
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'tags',
updateValue: tagsUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
break;
case 'status':
const statusUpdate = getTypedPayload<CaseStatuses>(value);
if (caseData.status !== value) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'status',
updateValue: statusUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
case 'settings':
const settingsUpdate = getTypedPayload<CaseAttributes['settings']>(value);
if (caseData.settings !== value) {
updateCaseProperty({
fetchCaseUserActions,
updateKey: 'settings',
updateValue: settingsUpdate,
updateCase: handleUpdateNewCase,
caseData,
onSuccess,
onError,
});
}
break;
default:
return null;
}
},
[fetchCaseUserActions, updateCaseProperty, updateCase, caseData]
);
const handleUpdateCase = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchCaseUserActions(caseId, newCase.connector.id, subCaseId);
},
[updateCase, fetchCaseUserActions, caseId, subCaseId]
);
const {
loading: isLoadingConnectors,
connectors,
permissionsError,
} = useConnectors({
toastPermissionsErrors: false,
});
const [connectorName, isValidConnector] = useMemo(() => {
const connector = connectors.find((c) => c.id === caseData.connector.id);
return [connector?.name ?? '', !!connector];
}, [connectors, caseData.connector]);
const currentExternalIncident = useMemo(
() =>
caseServices != null && caseServices[caseData.connector.id] != null
? caseServices[caseData.connector.id]
: null,
[caseServices, caseData.connector]
);
const onSubmitConnector = useCallback(
(connectorId, connectorFields, onError, onSuccess) => {
const connector = getConnectorById(connectorId, connectors);
const connectorToUpdate = connector
? normalizeActionConnector(connector)
: getNoneConnector();
onUpdateField({
key: 'connector',
value: { ...connectorToUpdate, fields: connectorFields },
onSuccess,
onError,
});
},
[onUpdateField, connectors]
);
const onSubmitTags = useCallback(
(newTags) => onUpdateField({ key: 'tags', value: newTags }),
[onUpdateField]
);
const onSubmitTitle = useCallback(
(newTitle) =>
onUpdateField({
key: 'title',
value: newTitle,
}),
[onUpdateField]
);
const changeStatus = useCallback(
(status: CaseStatuses) =>
onUpdateField({
key: 'status',
value: status,
}),
[onUpdateField]
);
const handleRefresh = useCallback(() => {
fetchCaseUserActions(caseId, caseData.connector.id, subCaseId);
fetchCase();
}, [caseData.connector.id, caseId, fetchCase, fetchCaseUserActions, subCaseId]);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
body: i18n.EMAIL_BODY(getCaseViewUrl({ detailName: caseId, subCaseId })),
}),
[caseData.title, getCaseViewUrl, caseId, subCaseId]
);
useEffect(() => {
if (initLoadingData && !isLoadingUserActions) {
setInitLoadingData(false);
}
}, [initLoadingData, isLoadingUserActions]);
const onShowAlertDetails = useCallback(
(alertId: string, index: string) => {
if (showAlertDetails) {
showAlertDetails(alertId, index);
}
},
[showAlertDetails]
);
// useEffect used for component's initialization
useEffect(() => {
if (init.current) {
init.current = false;
if (onComponentInitialized) {
onComponentInitialized();
}
}
}, [onComponentInitialized]);
return (
<>
<HeaderPage
showBackButton={true}
data-test-subj="case-view-title"
titleNode={
<EditableTitle
userCanCrud={userCanCrud}
isLoading={isLoading && updateKey === 'title'}
title={caseData.title}
onSubmit={onSubmitTitle}
/>
}
title={caseData.title}
>
<CaseActionBar
caseData={caseData}
currentExternalIncident={currentExternalIncident}
userCanCrud={userCanCrud}
isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')}
onRefresh={handleRefresh}
onUpdateField={onUpdateField}
/>
</HeaderPage>
<WhitePageWrapper>
<ContentWrapper>
<EuiFlexGroup>
<EuiFlexItem grow={6}>
{initLoadingData && (
<EuiLoadingContent lines={8} data-test-subj="case-view-loading-content" />
)}
{!initLoadingData && (
<>
<UserActionTree
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseServices={caseServices}
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
actionsNavigation={actionsNavigation}
fetchUserActions={fetchCaseUserActions.bind(
null,
caseId,
caseData.connector.id,
subCaseId
)}
isLoadingDescription={isLoading && updateKey === 'description'}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={onShowAlertDetails}
onUpdateField={onUpdateField}
renderInvestigateInTimelineActionComponent={
timelineUi?.renderInvestigateInTimelineActionComponent
}
statusActionButton={
caseData.type !== CaseType.collection && userCanCrud ? (
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
isLoading={isLoading && updateKey === 'status'}
/>
) : null
}
updateCase={updateCase}
useFetchAlertData={useFetchAlertData}
userCanCrud={userCanCrud}
/>
</>
)}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<UserList
data-test-subj="case-view-user-list-reporter"
email={emailContent}
headline={i18n.REPORTER}
users={[caseData.createdBy]}
/>
<UserList
data-test-subj="case-view-user-list-participants"
email={emailContent}
headline={i18n.PARTICIPANTS}
loading={isLoadingUserActions}
users={participants}
/>
<TagList
data-test-subj="case-view-tag-list"
userCanCrud={userCanCrud}
tags={caseData.tags}
onSubmit={onSubmitTags}
isLoading={isLoading && updateKey === 'tags'}
/>
<EditConnector
caseData={caseData}
caseServices={caseServices}
connectorName={connectorName}
connectors={connectors}
hasDataToPush={hasDataToPush && userCanCrud}
hideConnectorServiceNowSir={
subCaseId != null || caseData.type === CaseType.collection
}
isLoading={isLoadingConnectors || (isLoading && updateKey === 'connector')}
isValidConnector={isLoadingConnectors ? true : isValidConnector}
onSubmit={onSubmitConnector}
permissionsError={permissionsError}
updateCase={handleUpdateCase}
userActions={caseUserActions}
userCanCrud={userCanCrud}
/>
</EuiFlexItem>
</EuiFlexGroup>
</ContentWrapper>
</WhitePageWrapper>
{timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null}
</>
);
}
);
export const CaseViewLoading = () => (
<MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
@ -539,7 +83,7 @@ export const CaseView = React.memo(
data && (
<CasesTimelineIntegrationProvider timelineIntegration={timelineIntegration}>
{getLegacyUrlConflictCallout()}
<CaseComponent
<CaseViewPage
caseData={data}
caseId={caseId}
fetchCase={fetchCase}
@ -558,7 +102,6 @@ export const CaseView = React.memo(
}
);
CaseComponent.displayName = 'CaseComponent';
CaseViewLoading.displayName = 'CaseViewLoading';
CaseView.displayName = 'CaseView';

View file

@ -95,6 +95,10 @@ export const CASE_REFRESH = i18n.translate('xpack.cases.caseView.caseRefresh', {
defaultMessage: 'Refresh case',
});
export const ACTIVITY = i18n.translate('xpack.cases.caseView.activity', {
defaultMessage: 'Activity',
});
export const EMAIL_SUBJECT = (caseTitle: string) =>
i18n.translate('xpack.cases.caseView.emailSubject', {
values: { caseTitle },
@ -131,3 +135,28 @@ export const DOES_NOT_EXIST_DESCRIPTION = (caseId: string) =>
export const DOES_NOT_EXIST_BUTTON = i18n.translate('xpack.cases.caseView.doesNotExist.button', {
defaultMessage: 'Back to Cases',
});
export const TOTAL_ALERTS_METRIC = i18n.translate('xpack.cases.caseView.metrics.totalAlerts', {
defaultMessage: 'Total Alerts',
});
export const ASSOCIATED_USERS_METRIC = i18n.translate(
'xpack.cases.caseView.metrics.associatedUsers',
{
defaultMessage: 'Associated Users',
}
);
export const ASSOCIATED_HOSTS_METRIC = i18n.translate(
'xpack.cases.caseView.metrics.associatedHosts',
{
defaultMessage: 'Associated Hosts',
}
);
export const TOTAL_CONNECTORS_METRIC = i18n.translate(
'xpack.cases.caseView.metrics.totalConnectors',
{
defaultMessage: 'Total Connectors',
}
);

View file

@ -0,0 +1,43 @@
/*
* 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 { MutableRefObject } from 'react';
import { CasesTimelineIntegration } from '../timeline_context';
import { CasesNavigation } from '../links';
import { CaseViewRefreshPropInterface, Ecs, Case } from '../../../common';
import { UseGetCase } from '../../containers/use_get_case';
export interface CaseViewBaseProps {
onComponentInitialized?: () => void;
actionsNavigation?: CasesNavigation<string, 'configurable'>;
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
showAlertDetails?: (alertId: string, index: string) => void;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
/**
* A React `Ref` that Exposes data refresh callbacks.
* **NOTE**: Do not hold on to the `.current` object, as it could become stale
*/
refreshRef?: MutableRefObject<CaseViewRefreshPropInterface>;
}
export interface CaseViewProps extends CaseViewBaseProps {
timelineIntegration?: CasesTimelineIntegration;
}
export interface CaseViewPageProps extends CaseViewBaseProps {
caseId: string;
subCaseId?: string;
fetchCase: UseGetCase['fetchCase'];
caseData: Case;
updateCase: (newCase: Case) => void;
}
export interface OnUpdateFields {
key: keyof Case;
value: Case[keyof Case];
onSuccess?: () => void;
onError?: () => void;
}

View file

@ -7,7 +7,7 @@
import React, { useState, useEffect } from 'react';
import { merge } from 'lodash';
import { CasesContextValue } from '../../../common/ui/types';
import { CasesContextValue, CasesFeatures } from '../../../common/ui/types';
import { DEFAULT_FEATURES } from '../../../common/constants';
import { DEFAULT_BASE_PATH } from '../../common/navigation';
import { useApplication } from './use_application';
@ -17,7 +17,7 @@ export const CasesContext = React.createContext<CasesContextValue | undefined>(u
export interface CasesContextProps
extends Omit<CasesContextValue, 'appId' | 'appTitle' | 'basePath' | 'features'> {
basePath?: string;
features?: Partial<CasesContextValue['features']>;
features?: CasesFeatures;
}
export interface CasesContextStateValue extends Omit<CasesContextValue, 'appId' | 'appTitle'> {
@ -30,7 +30,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
value: { owner, userCanCrud, basePath = DEFAULT_BASE_PATH, features = {} },
}) => {
const { appId, appTitle } = useApplication();
const [value, setValue] = useState<CasesContextStateValue>({
const [value, setValue] = useState<CasesContextStateValue>(() => ({
owner,
userCanCrud,
basePath,
@ -39,7 +39,7 @@ export const CasesProvider: React.FC<{ value: CasesContextProps }> = ({
* of the DEFAULT_FEATURES object
*/
features: merge({}, DEFAULT_FEATURES, features),
});
}));
/**
* `userCanCrud` prop may change by the parent plugin.

View file

@ -6,17 +6,22 @@
*/
import { useMemo } from 'react';
import { CaseMetricsFeature } from '../../containers/types';
import { useCasesContext } from './use_cases_context';
interface UseCasesFeaturesReturn {
interface UseCasesFeatures {
isSyncAlertsEnabled: boolean;
metricsFeatures: CaseMetricsFeature[];
}
export const useCasesFeatures = (): UseCasesFeaturesReturn => {
export const useCasesFeatures = (): UseCasesFeatures => {
const { features } = useCasesContext();
const memoizedReturnValue = useMemo(
() => ({ isSyncAlertsEnabled: features.alerts.sync }),
const casesFeatures = useMemo(
() => ({
isSyncAlertsEnabled: features.alerts.sync,
metricsFeatures: features.metrics,
}),
[features]
);
return memoizedReturnValue;
return casesFeatures;
};

View file

@ -35,7 +35,7 @@ import {
} from '../../../common/api';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { parseStringAsExternalService } from '../../common/user_actions';
import { OnUpdateFields } from '../case_view';
import type { OnUpdateFields } from '../case_view/types';
import {
getConnectorLabelTitle,
getLabelTitle,

View file

@ -19,6 +19,7 @@ import {
actionLicenses,
allCases,
basicCase,
basicCaseMetrics,
basicCaseCommentPatch,
basicCasePost,
basicResolvedCase,
@ -35,6 +36,7 @@ import {
CommentRequest,
User,
CaseStatuses,
CaseMetricsResponse,
} from '../../../common/api';
export const getCase = async (
@ -49,6 +51,11 @@ export const resolveCase = async (
signal: AbortSignal
): Promise<ResolvedCase> => Promise.resolve(basicResolvedCase);
export const getCaseMetrics = async (
caseId: string,
signal: AbortSignal
): Promise<CaseMetricsResponse> => Promise.resolve(basicCaseMetrics);
export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> =>
Promise.resolve(casesStatus);

View file

@ -22,6 +22,7 @@ import {
CommentType,
getCaseCommentsUrl,
getCaseDetailsUrl,
getCaseDetailsMetricsUrl,
getCasePushUrl,
getCaseUserActionUrl,
getSubCaseDetailsUrl,
@ -30,6 +31,7 @@ import {
SubCaseResponse,
SubCasesResponse,
User,
CaseMetricsResponse,
} from '../../common/api';
import {
CASE_REPORTERS_URL,
@ -48,6 +50,8 @@ import {
AllCases,
BulkUpdateStatus,
Case,
CaseMetrics,
CaseMetricsFeature,
CasesStatus,
FetchCasesProps,
SortFieldCase,
@ -64,6 +68,7 @@ import {
decodeCasesStatusResponse,
decodeCaseUserActionsResponse,
decodeCaseResolveResponse,
decodeCaseMetricsResponse,
} from './utils';
export const getCase = async (
@ -157,6 +162,22 @@ export const getReporters = async (signal: AbortSignal, owner: string[]): Promis
return response ?? [];
};
export const getCaseMetrics = async (
caseId: string,
features: CaseMetricsFeature[],
signal: AbortSignal
): Promise<CaseMetrics> => {
const response = await KibanaServices.get().http.fetch<CaseMetricsResponse>(
getCaseDetailsMetricsUrl(caseId),
{
method: 'GET',
signal,
query: { features: JSON.stringify(features) },
}
);
return convertToCamelCase<CaseMetricsResponse, CaseMetrics>(decodeCaseMetricsResponse(response));
};
export const getCaseUserActions = async (
caseId: string,
signal: AbortSignal

View file

@ -8,7 +8,7 @@
import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } from './types';
import { isCreateConnector, isPush, isUpdateConnector } from '../../common/utils/user_actions';
import { ResolvedCase } from '../../common/ui/types';
import { CaseMetrics, CaseMetricsFeature, ResolvedCase } from '../../common/ui/types';
import {
AssociationType,
CaseUserActionConnector,
@ -34,6 +34,7 @@ export const basicSubCaseId = 'basic-sub-case-id';
const basicCommentId = 'basic-comment-id';
const basicCreatedAt = '2020-02-19T23:06:33.798Z';
const basicUpdatedAt = '2020-02-20T15:02:57.995Z';
const basicClosedAt = '2020-02-21T15:02:57.995Z';
const laterTime = '2020-02-28T15:02:57.995Z';
export const elasticUser = {
@ -168,6 +169,32 @@ export const basicResolvedCase: ResolvedCase = {
aliasTargetId: `${basicCase.id}_2`,
};
export const basicCaseMetricsFeatures: CaseMetricsFeature[] = [
'alerts.count',
'alerts.users',
'alerts.hosts',
'connectors',
];
export const basicCaseMetrics: CaseMetrics = {
alerts: {
count: 12,
hosts: {
total: 2,
values: [
{ name: 'foo', count: 2 },
{ name: 'bar', count: 10 },
],
},
users: {
total: 1,
values: [{ name: 'Jon', count: 12 }],
},
},
connectors: { total: 1 },
lifespan: { creationDate: basicCreatedAt, closeDate: basicClosedAt },
};
export const collectionCase: Case = {
type: CaseType.collection,
owner: SECURITY_SOLUTION_OWNER,

View file

@ -0,0 +1,140 @@
/*
* 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 { renderHook, act } from '@testing-library/react-hooks';
import { CaseMetricsFeature } from '../../common/ui';
import { useGetCaseMetrics, UseGetCaseMetrics } from './use_get_case_metrics';
import { basicCase, basicCaseMetrics } from './mock';
import * as api from './api';
jest.mock('./api');
jest.mock('../common/lib/kibana');
describe('useGetCaseMetrics', () => {
const abortCtrl = new AbortController();
const features: CaseMetricsFeature[] = ['alerts.count'];
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
expect(result.current).toEqual({
metrics: null,
isLoading: false,
isError: false,
fetchCaseMetrics: result.current.fetchCaseMetrics,
});
});
});
it('calls getCaseMetrics with correct arguments', async () => {
const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics');
await act(async () => {
const { waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(spyOnGetCaseMetrics).toBeCalledWith(basicCase.id, features, abortCtrl.signal);
});
});
it('does not call getCaseMetrics if empty feature parameter passed', async () => {
const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics');
await act(async () => {
const { waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, [])
);
await waitForNextUpdate();
expect(spyOnGetCaseMetrics).not.toBeCalled();
});
});
it('fetch case metrics', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
metrics: basicCaseMetrics,
isLoading: false,
isError: false,
fetchCaseMetrics: result.current.fetchCaseMetrics,
});
});
});
it('refetch case metrics', async () => {
const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics');
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCaseMetrics();
expect(spyOnGetCaseMetrics).toHaveBeenCalledTimes(2);
});
});
it('set isLoading to true when refetching case metrics', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCaseMetrics();
expect(result.current.isLoading).toBe(true);
});
});
it('set isLoading to false when refetching case metrics "silent"ly', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
result.current.fetchCaseMetrics(true);
expect(result.current.isLoading).toBe(false);
});
});
it('returns an error when getCaseMetrics throws', async () => {
const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics');
spyOnGetCaseMetrics.mockImplementation(() => {
throw new Error('Something went wrong');
});
await act(async () => {
const { result, waitForNextUpdate } = renderHook<string, UseGetCaseMetrics>(() =>
useGetCaseMetrics(basicCase.id, features)
);
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
metrics: null,
isLoading: false,
isError: true,
fetchCaseMetrics: result.current.fetchCaseMetrics,
});
});
});
});

View file

@ -0,0 +1,118 @@
/*
* 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 { useEffect, useReducer, useCallback, useRef } from 'react';
import { CaseMetrics, CaseMetricsFeature } from './types';
import * as i18n from './translations';
import { useToasts } from '../common/lib/kibana';
import { getCaseMetrics } from './api';
interface CaseMeticsState {
metrics: CaseMetrics | null;
isLoading: boolean;
isError: boolean;
}
type Action =
| { type: 'FETCH_INIT'; payload: { silent: boolean } }
| { type: 'FETCH_SUCCESS'; payload: CaseMetrics }
| { type: 'FETCH_FAILURE' };
const dataFetchReducer = (state: CaseMeticsState, action: Action): CaseMeticsState => {
switch (action.type) {
case 'FETCH_INIT':
return {
...state,
isLoading: !action.payload?.silent,
isError: false,
};
case 'FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
metrics: action.payload,
};
case 'FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
default:
return state;
}
};
export interface UseGetCaseMetrics extends CaseMeticsState {
/**
* @param [silent] When set to `true`, the `isLoading` property will not be set to `true`
* while doing the API call
*/
fetchCaseMetrics: (silent?: boolean) => Promise<void>;
}
export const useGetCaseMetrics = (
caseId: string,
features: CaseMetricsFeature[]
): UseGetCaseMetrics => {
const [state, dispatch] = useReducer(dataFetchReducer, {
metrics: null,
isLoading: false,
isError: false,
});
const toasts = useToasts();
const isCancelledRef = useRef(false);
const abortCtrlRef = useRef(new AbortController());
const callFetch = useCallback(
async (silent: boolean = false) => {
if (features.length === 0) {
return;
}
try {
isCancelledRef.current = false;
abortCtrlRef.current.abort();
abortCtrlRef.current = new AbortController();
dispatch({ type: 'FETCH_INIT', payload: { silent } });
const response: CaseMetrics = await getCaseMetrics(
caseId,
features,
abortCtrlRef.current.signal
);
if (!isCancelledRef.current) {
dispatch({ type: 'FETCH_SUCCESS', payload: response });
}
} 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 }
);
}
dispatch({ type: 'FETCH_FAILURE' });
}
}
},
[caseId, features, toasts]
);
useEffect(() => {
callFetch();
return () => {
isCancelledRef.current = true;
abortCtrlRef.current.abort();
};
}, [callFetch]);
return { ...state, fetchCaseMetrics: callFetch };
};

View file

@ -32,6 +32,8 @@ import {
CasePatchRequest,
CaseResolveResponse,
CaseResolveResponseRt,
CaseMetricsResponse,
CaseMetricsResponseRt,
} from '../../common/api';
import { AllCases, Case, UpdateByKey } from './types';
import * as i18n from './translations';
@ -88,6 +90,12 @@ export const decodeCaseResolveResponse = (respCase?: CaseResolveResponse) =>
fold(throwErrors(createToasterPlainError), identity)
);
export const decodeCaseMetricsResponse = (respCase?: CaseMetricsResponse) =>
pipe(
CaseMetricsResponseRt.decode(respCase),
fold(throwErrors(createToasterPlainError), identity)
);
export const decodeCasesResponse = (respCase?: CasesResponse) =>
pipe(CasesResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity));

View file

@ -16,7 +16,7 @@ export class AlertDetails implements MetricsHandler {
private retrievedMetrics: boolean = false;
public getFeatures(): Set<string> {
return new Set(['alertHosts', 'alertUsers']);
return new Set(['alerts.hosts', 'alerts.users']);
}
public async compute(): Promise<CaseMetricsResponse> {

View file

@ -20,7 +20,7 @@ export class AlertsCount implements MetricsHandler {
) {}
public getFeatures(): Set<string> {
return new Set(['alertsCount']);
return new Set(['alerts.count']);
}
public async compute(): Promise<CaseMetricsResponse> {

View file

@ -15,7 +15,7 @@ export class Connectors implements MetricsHandler {
public async compute(): Promise<CaseMetricsResponse> {
return {
connectors: [],
connectors: { total: 0 },
};
}
}

View file

@ -78,9 +78,9 @@ describe('getMetrics', () => {
});
});
it('populates the alertHosts and alertUsers sections', async () => {
it('populates the alerts.hosts and alerts.users sections', async () => {
const metrics = await getCaseMetrics(
{ caseId: '', features: ['alertHosts'] },
{ caseId: '', features: ['alerts.hosts', 'alerts.users'] },
client,
clientArgs
);
@ -91,7 +91,7 @@ describe('getMetrics', () => {
it('populates multiple sections at a time', async () => {
const metrics = await getCaseMetrics(
{ caseId: '', features: ['alertsCount', 'lifespan'] },
{ caseId: '', features: ['alerts.count', 'lifespan'] },
client,
clientArgs
);
@ -105,7 +105,7 @@ describe('getMetrics', () => {
it('populates multiple alerts sections at a time', async () => {
const metrics = await getCaseMetrics(
{ caseId: '', features: ['alertsCount', 'alertHosts'] },
{ caseId: '', features: ['alerts.count', 'alerts.hosts'] },
client,
clientArgs
);
@ -127,7 +127,7 @@ describe('getMetrics', () => {
try {
await getCaseMetrics(
{ caseId: '', features: ['bananas', 'lifespan', 'alertsCount'] },
{ caseId: '', features: ['bananas', 'lifespan', 'alerts.count'] },
client,
clientArgs
);

View file

@ -27,7 +27,7 @@ export const CaseDetailsRefreshContext =
* const caseDetailsRefresh = useWithCaseDetailsRefresh();
* ...
* if (caseDetailsRefresh) {
* caseDetailsRefresh.refreshUserActionsAndComments();
* caseDetailsRefresh.refreshCase();
* }
*/
export const useWithCaseDetailsRefresh = (): Readonly<CaseViewRefreshPropInterface> | undefined => {

View file

@ -160,7 +160,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
setIsIsolateActionSuccessBannerVisible(true);
// If a case details refresh ref is defined, then refresh actions and comments
if (caseDetailsRefresh) {
caseDetailsRefresh.refreshUserActionsAndComments();
caseDetailsRefresh.refreshCase();
}
}, [caseDetailsRefresh]);

View file

@ -7,7 +7,7 @@
import React, { memo, useMemo, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { CaseStatuses, StatusAll, CasesContextValue } from '../../../../../../cases/common';
import { CaseStatuses, StatusAll, CasesFeatures } from '../../../../../../cases/common';
import { TimelineItem } from '../../../../../common/search_strategy';
import { useAddToCase, normalizedEventFields } from '../../../../hooks/use_add_to_case';
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
@ -24,7 +24,7 @@ export interface AddToCaseActionProps {
appId: string;
owner: string;
onClose?: Function;
casesFeatures?: CasesContextValue['features'];
casesFeatures?: CasesFeatures;
}
const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({

View file

@ -6,7 +6,7 @@
*/
import expect from '@kbn/expect';
import { getPostCaseRequest, postCommentAlertReq } from '../../../..//common/lib/mock';
import { getPostCaseRequest, postCommentAlertReq } from '../../../../common/lib/mock';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -89,7 +89,7 @@ export default ({ getService }: FtrProviderContext): void => {
const metrics = await getCaseMetrics({
supertest,
caseId: theCase.id,
features: ['alertsCount'],
features: ['alerts.count'],
});
expect(metrics).to.eql({
@ -116,7 +116,7 @@ export default ({ getService }: FtrProviderContext): void => {
const metrics = await getCaseMetrics({
supertest,
caseId: theCase.id,
features: ['alertsCount'],
features: ['alerts.count'],
});
expect(metrics).to.eql({