mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
ce153c597d
commit
52a9d60e3b
33 changed files with 1806 additions and 1131 deletions
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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)`
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
|
|
43
x-pack/plugins/cases/public/components/case_view/types.ts
Normal file
43
x-pack/plugins/cases/public/components/case_view/types.ts
Normal 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;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
118
x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx
Normal file
118
x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx
Normal 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 };
|
||||
};
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -15,7 +15,7 @@ export class Connectors implements MetricsHandler {
|
|||
|
||||
public async compute(): Promise<CaseMetricsResponse> {
|
||||
return {
|
||||
connectors: [],
|
||||
connectors: { total: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -27,7 +27,7 @@ export const CaseDetailsRefreshContext =
|
|||
* const caseDetailsRefresh = useWithCaseDetailsRefresh();
|
||||
* ...
|
||||
* if (caseDetailsRefresh) {
|
||||
* caseDetailsRefresh.refreshUserActionsAndComments();
|
||||
* caseDetailsRefresh.refreshCase();
|
||||
* }
|
||||
*/
|
||||
export const useWithCaseDetailsRefresh = (): Readonly<CaseViewRefreshPropInterface> | undefined => {
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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> = ({
|
||||
|
|
|
@ -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({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue