[SecuritySolution] Cases by status (#130670)

* init cases by status

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* cases by status

* tickLabel

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* unit tests

* unit tests

* rm unused dependency

* disable dashboard

* dependency

* enable dashboard

* styling

* styling

* unit tests

* styling

* unit tests

* disable dashboard

* fix types

* fix types

* unit tests

* unit test

* unit tests

* unit tests

* unit tests

* unit tests

* unit tests

* update i18n

* remove cases api hack

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Angela Chuang 2022-04-27 18:18:07 +01:00 committed by GitHub
parent f5f035645e
commit 56d0112a22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 664 additions and 45 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { Chart, BarSeries, Axis, ScaleType } from '@elastic/charts';
import { Chart, BarSeries, Axis, ScaleType, AxisStyle } from '@elastic/charts';
import { mount, ReactWrapper, shallow, ShallowWrapper } from 'enzyme';
import React from 'react';
@ -166,13 +166,43 @@ describe('BarChartBaseComponent', () => {
describe('render with customized configs', () => {
const mockNumberFormatter = jest.fn();
const mockXAxisStyle = {
tickLine: {
size: 0,
},
tickLabel: {
padding: 16,
fontSize: 10.5,
},
} as Partial<AxisStyle>;
const mockYAxisStyle = {
tickLine: {
size: 0,
},
tickLabel: {
padding: 16,
fontSize: 14,
},
} as Partial<AxisStyle>;
const configs = {
series: {
xScaleType: ScaleType.Ordinal,
yScaleType: ScaleType.Linear,
barSeriesStyle: {
rect: {
widthPixel: 22,
opacity: 1,
},
},
},
axis: {
yTickFormatter: mockNumberFormatter,
bottom: {
style: mockXAxisStyle,
},
left: {
style: mockYAxisStyle,
},
},
};
@ -203,12 +233,22 @@ describe('BarChartBaseComponent', () => {
);
});
it('should render BarSeries with given barSeriesStyle', () => {
expect(shallowWrapper.find(BarSeries).first().prop('barSeriesStyle')).toEqual(
configs.series.barSeriesStyle
);
});
it('should render xAxis with given tick formatter', () => {
expect(shallowWrapper.find(Axis).first().prop('tickFormat')).toBeUndefined();
});
it('should render xAxis style', () => {
expect(shallowWrapper.find(Axis).first().prop('style')).toEqual(mockXAxisStyle);
});
it('should render yAxis with given tick formatter', () => {
expect(shallowWrapper.find(Axis).last().prop('tickFormat')).toEqual(mockNumberFormatter);
expect(shallowWrapper.find(Axis).last().prop('style')).toEqual(mockYAxisStyle);
});
});

View file

@ -87,6 +87,34 @@ export const BarChartBaseComponent = ({
...deepmerge(get('configs.settings', chartConfigs), { theme }),
};
const xAxisStyle = useMemo(
() =>
deepmerge(
{
tickLine: {
size: tickSize,
},
},
getOr({}, 'configs.axis.bottom.style', chartConfigs)
),
[chartConfigs, tickSize]
);
const yAxisStyle = useMemo(
() =>
deepmerge(
{
tickLine: {
size: tickSize,
},
},
getOr({}, 'configs.axis.left.style', chartConfigs)
),
[chartConfigs, tickSize]
);
const xAxisLabelFormat = get('configs.axis.bottom.labelFormat', chartConfigs);
return chartConfigs.width && chartConfigs.height ? (
<Chart>
<Settings {...settings} showLegend={settings.showLegend && !forceHiddenLegend} />
@ -106,6 +134,7 @@ export const BarChartBaseComponent = ({
data={series.value ?? []}
stackAccessors={get('configs.series.stackAccessors', chartConfigs)}
color={series.color ? series.color : undefined}
barSeriesStyle={get('configs.series.barSeriesStyle', chartConfigs)}
/>
) : null;
})}
@ -114,22 +143,15 @@ export const BarChartBaseComponent = ({
id={xAxisId}
position={Position.Bottom}
showOverlappingTicks={false}
style={{
tickLine: {
size: tickSize,
},
}}
style={xAxisStyle}
tickFormat={xTickFormatter}
labelFormat={xAxisLabelFormat}
/>
<Axis
id={yAxisId}
position={Position.Left}
style={{
tickLine: {
size: tickSize,
},
}}
style={yAxisStyle}
tickFormat={yTickFormatter}
title={yAxisTitle}
/>
@ -143,7 +165,7 @@ export const BarChartBase = React.memo(BarChartBaseComponent);
BarChartBase.displayName = 'BarChartBase';
interface BarChartComponentProps {
export interface BarChartComponentProps {
barChart: ChartSeriesData[] | null | undefined;
configs?: ChartSeriesConfigs | undefined;
stackByField?: string;

View file

@ -17,6 +17,8 @@ import {
TickFormatter,
Position,
BrushEndListener,
AxisStyle,
BarSeriesStyle,
} from '@elastic/charts';
import React, { useMemo } from 'react';
import styled from 'styled-components';
@ -45,11 +47,20 @@ export interface ChartSeriesConfigs {
xScaleType?: ScaleType | undefined;
yScaleType?: ScaleType | undefined;
stackAccessors?: string[] | undefined;
barSeriesStyle?: Partial<BarSeriesStyle>;
};
axis?: {
xTickFormatter?: TickFormatter | undefined;
yTickFormatter?: TickFormatter | undefined;
tickSize?: number | undefined;
left?: {
style?: Partial<AxisStyle>;
labelFormat?: (d: unknown) => string;
};
bottom?: {
style?: Partial<AxisStyle>;
labelFormat?: (d: unknown) => string;
};
};
yAxisTitle?: string | undefined;
settings?: SettingsProps;

View file

@ -105,12 +105,12 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
[]
);
const openCount = donutData?.open?.total ?? 0;
const acknowledgedCount = donutData?.acknowledged?.total ?? 0;
const closedCount = donutData?.closed?.total ?? 0;
const totalAlerts =
loading || donutData == null
? 0
: (donutData?.open?.total ?? 0) +
(donutData?.acknowledged?.total ?? 0) +
(donutData?.closed?.total ?? 0);
loading || donutData == null ? 0 : openCount + acknowledgedCount + closedCount;
const fillColor: FillColor = useCallback((d: ShapeTreeNode) => {
return chartConfigs.find((cfg) => cfg.label === d.dataName)?.color ?? emptyDonutColor;
@ -152,10 +152,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
<>
<EuiFlexGroup justifyContent="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiText className="eui-textCenter" size="s">
{loading ? (
<EuiSpacer size="l" />
) : (
{totalAlerts !== 0 && (
<EuiText className="eui-textCenter" size="s">
<>
<b>
<FormattedCount count={totalAlerts} />
@ -163,9 +161,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
<> </>
<small>{ALERTS(totalAlerts)}</small>
</>
)}
</EuiText>
</EuiText>
)}
<EuiSpacer size="l" />
<EuiFlexGroup justifyContent="center">
<StyledFlexItem key="alerts-status-open" grow={false}>
@ -174,8 +171,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
fillColor={fillColor}
height={donutHeight}
label={STATUS_OPEN}
title={<ChartLabel count={donutData?.open?.total ?? 0} />}
totalCount={donutData?.open?.total ?? 0}
title={<ChartLabel count={openCount} />}
totalCount={openCount}
/>
</StyledFlexItem>
<StyledFlexItem key="alerts-status-acknowledged" grow={false}>
@ -184,8 +181,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
fillColor={fillColor}
height={donutHeight}
label={STATUS_ACKNOWLEDGED}
title={<ChartLabel count={donutData?.acknowledged?.total ?? 0} />}
totalCount={donutData?.acknowledged?.total ?? 0}
title={<ChartLabel count={acknowledgedCount} />}
totalCount={acknowledgedCount}
/>
</StyledFlexItem>
<StyledFlexItem key="alerts-status-closed" grow={false}>
@ -194,8 +191,8 @@ export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => {
fillColor={fillColor}
height={donutHeight}
label={STATUS_CLOSED}
title={<ChartLabel count={donutData?.closed?.total ?? 0} />}
totalCount={donutData?.closed?.total ?? 0}
title={<ChartLabel count={closedCount} />}
totalCount={closedCount}
/>
</StyledFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { BarChartComponentProps } from '../../../../common/components/charts/barchart';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { TestProviders } from '../../../../common/mock';
import { CasesByStatus } from './cases_by_status';
jest.mock('../../../../common/components/link_to');
jest.mock('../../../../common/containers/query_toggle');
jest.mock('./use_cases_by_status', () => ({
useCasesByStatus: jest.fn().mockReturnValue({
closed: 1,
inProgress: 2,
isLoading: false,
open: 3,
totalCounts: 6,
updatedAt: new Date('2022-04-08T12:00:00.000Z').valueOf(),
}),
}));
jest.mock('../../../../common/lib/kibana', () => {
const actual = jest.requireActual('../../../../common/lib/kibana');
return {
...actual,
useNavigation: jest.fn().mockReturnValue({
getAppUrl: jest.fn(),
navigateTo: jest.fn(),
}),
};
});
jest.mock('../../../../common/components/charts/barchart', () => ({
BarChart: jest.fn((props: BarChartComponentProps) => <div data-test-subj="barChart" />),
}));
const mockSetToggle = jest.fn();
(useQueryToggle as jest.Mock).mockReturnValue({
toggleStatus: true,
setToggleStatus: mockSetToggle,
});
describe('CasesByStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders title', () => {
render(
<TestProviders>
<CasesByStatus />
</TestProviders>
);
expect(screen.getByTestId('header-section-title')).toHaveTextContent('Cases');
});
test('renders toggleQuery', () => {
render(
<TestProviders>
<CasesByStatus />
</TestProviders>
);
expect(screen.getByTestId('query-toggle-header')).toBeInTheDocument();
});
test('renders BarChart', () => {
render(
<TestProviders>
<CasesByStatus />
</TestProviders>
);
expect(screen.getByTestId('chart-wrapper')).toBeInTheDocument();
expect(screen.queryByTestId('bar-chart-mask')).not.toBeInTheDocument();
});
test('collapses content', () => {
(useQueryToggle as jest.Mock).mockReturnValueOnce({
toggleStatus: false,
setToggleStatus: mockSetToggle,
});
render(
<TestProviders>
<CasesByStatus />
</TestProviders>
);
expect(screen.queryByTestId('chart-wrapper')).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,200 @@
/*
* 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, useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiPanel, EuiText } from '@elastic/eui';
import { AxisStyle, Rotation, ScaleType } from '@elastic/charts';
import styled from 'styled-components';
import { FormattedNumber } from '@kbn/i18n-react';
import numeral from '@elastic/numeral';
import { BarChart } from '../../../../common/components/charts/barchart';
import { LastUpdatedAt } from '../util';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { HeaderSection } from '../../../../common/components/header_section';
import {
CASES,
CASES_BY_STATUS_SECTION_TITLE,
STATUS_CLOSED,
STATUS_IN_PROGRESS,
STATUS_OPEN,
VIEW_CASES,
} from '../translations';
import { LinkButton } from '../../../../common/components/links';
import { useCasesByStatus } from './use_cases_by_status';
import { SecurityPageName } from '../../../../../common/constants';
import { useFormatUrl } from '../../../../common/components/link_to';
import { appendSearch } from '../../../../common/components/link_to/helpers';
import { useNavigation } from '../../../../common/lib/kibana';
const CASES_BY_STATUS_ID = 'casesByStatus';
export const numberFormatter = (value: string | number): string => value.toLocaleString();
export const barchartConfigs = {
series: {
xScaleType: ScaleType.Ordinal,
yScaleType: ScaleType.Linear,
stackAccessors: ['g'],
barSeriesStyle: {
rect: {
widthPixel: 22,
opacity: 1,
},
},
},
axis: {
xTickFormatter: numberFormatter,
left: {
style: {
tickLine: {
size: 0,
},
tickLabel: {
padding: 16,
fontSize: 14,
},
} as Partial<AxisStyle>,
},
bottom: {
style: {
tickLine: {
size: 0,
},
tickLabel: {
padding: 16,
fontSize: 10.5,
},
} as Partial<AxisStyle>,
labelFormat: (d: unknown) => numeral(d).format('0'),
},
},
settings: {
rotation: 90 as Rotation,
},
customHeight: 146,
};
const barColors = {
empty: 'rgba(105, 112, 125, 0.1)',
open: '#79aad9',
'in-progress': '#f1d86f',
closed: '#d3dae6',
};
export const emptyChartSettings = [
{
key: 'open',
value: [{ y: 20, x: STATUS_OPEN, g: STATUS_OPEN }],
color: barColors.empty,
},
{
key: 'in-progress',
value: [{ y: 20, x: STATUS_IN_PROGRESS, g: STATUS_IN_PROGRESS }],
color: barColors.empty,
},
{
key: 'closed',
value: [{ y: 20, x: STATUS_CLOSED, g: STATUS_CLOSED }],
color: barColors.empty,
},
];
const StyledEuiFlexItem = styled(EuiFlexItem)`
align-items: center;
width: 70%;
`;
const Wrapper = styled.div`
width: 100%;
`;
const CasesByStatusComponent: React.FC = () => {
const { toggleStatus, setToggleStatus } = useQueryToggle(CASES_BY_STATUS_ID);
const { getAppUrl, navigateTo } = useNavigation();
const { search } = useFormatUrl(SecurityPageName.case);
const caseUrl = getAppUrl({ deepLinkId: SecurityPageName.case, path: appendSearch(search) });
const goToCases = useCallback(
(ev) => {
ev.preventDefault();
navigateTo({ url: caseUrl });
},
[caseUrl, navigateTo]
);
const { closed, inProgress, isLoading, open, totalCounts, updatedAt } = useCasesByStatus({
skip: !toggleStatus,
});
const chartData = useMemo(
() => [
{
key: 'open',
value: [{ y: open, x: STATUS_OPEN, g: STATUS_OPEN }],
color: barColors.open,
},
{
key: 'in-progress',
value: [{ y: inProgress, x: STATUS_IN_PROGRESS, g: STATUS_IN_PROGRESS }],
color: barColors['in-progress'],
},
{
key: 'closed',
value: [{ y: closed, x: STATUS_CLOSED, g: STATUS_CLOSED }],
color: barColors.closed,
},
],
[closed, inProgress, open]
);
return (
<EuiPanel hasBorder>
<HeaderSection
id={CASES_BY_STATUS_ID}
title={CASES_BY_STATUS_SECTION_TITLE}
titleSize="s"
toggleStatus={toggleStatus}
toggleQuery={setToggleStatus}
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}
showInspectButton={false}
>
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<LinkButton href={caseUrl} onClick={goToCases}>
{VIEW_CASES}
</LinkButton>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderSection>
{!isLoading && toggleStatus && (
<EuiFlexGroup justifyContent="center" alignItems="center" direction="column" gutterSize="s">
<EuiFlexItem>
{totalCounts !== 0 && (
<EuiText className="eui-textCenter" size="s" grow={false}>
<>
<b>
<FormattedNumber value={totalCounts} />
</b>
<> </>
<small>
<EuiLink onClick={goToCases}>{CASES(totalCounts)}</EuiLink>
</small>
</>
</EuiText>
)}
</EuiFlexItem>
<StyledEuiFlexItem grow={false}>
<Wrapper data-test-subj="chart-wrapper">
<BarChart configs={barchartConfigs} barChart={chartData} />
</Wrapper>
</StyledEuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
);
};
export const CasesByStatus = React.memo(CasesByStatusComponent);

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { CasesByStatus } from './cases_by_status';

View file

@ -0,0 +1,123 @@
/*
* 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 { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { useKibana } from '../../../../common/lib/kibana';
import { TestProviders } from '../../../../common/mock';
import {
useCasesByStatus,
UseCasesByStatusProps,
UseCasesByStatusResults,
} from './use_cases_by_status';
const dateNow = new Date('2022-04-08T12:00:00.000Z').valueOf();
const mockDateNow = jest.fn().mockReturnValue(dateNow);
Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now'];
jest.mock('../../../../common/containers/use_global_time', () => {
return {
useGlobalTime: jest
.fn()
.mockReturnValue({ from: '2022-04-05T12:00:00.000Z', to: '2022-04-08T12:00:00.000Z' }),
};
});
jest.mock('../../../../common/lib/kibana');
const mockGetAllCasesMetrics = jest.fn();
mockGetAllCasesMetrics.mockResolvedValue({
count_open_cases: 1,
count_in_progress_cases: 2,
count_closed_cases: 3,
});
mockGetAllCasesMetrics.mockResolvedValueOnce({
count_open_cases: 0,
count_in_progress_cases: 0,
count_closed_cases: 0,
});
const mockUseKibana = {
services: {
cases: {
...mockCasesContract(),
api: {
cases: {
getAllCasesMetrics: mockGetAllCasesMetrics,
},
},
},
},
};
(useKibana as jest.Mock).mockReturnValue(mockUseKibana);
describe('useCasesByStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('init', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseCasesByStatusProps,
UseCasesByStatusResults
>(() => useCasesByStatus({ skip: false }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
expect(result.current).toEqual({
closed: 0,
inProgress: 0,
isLoading: true,
open: 0,
totalCounts: 0,
updatedAt: dateNow,
});
});
});
test('fetch data', async () => {
await act(async () => {
const { result, waitForNextUpdate } = renderHook<
UseCasesByStatusProps,
UseCasesByStatusResults
>(() => useCasesByStatus({ skip: false }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
await waitForNextUpdate();
expect(result.current).toEqual({
closed: 3,
inProgress: 2,
isLoading: false,
open: 1,
totalCounts: 6,
updatedAt: dateNow,
});
});
});
test('skip', async () => {
const abortSpy = jest.spyOn(AbortController.prototype, 'abort');
await act(async () => {
const localProps = { skip: false };
const { rerender, waitForNextUpdate } = renderHook<
UseCasesByStatusProps,
UseCasesByStatusResults
>(() => useCasesByStatus(localProps), {
wrapper: TestProviders,
});
await waitForNextUpdate();
await waitForNextUpdate();
localProps.skip = true;
act(() => rerender());
act(() => rerender());
expect(abortSpy).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -0,0 +1,93 @@
/*
* 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 { useState, useEffect } from 'react';
import { APP_ID } from '../../../../../common/constants';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useKibana } from '../../../../common/lib/kibana';
export interface CasesCounts {
count_open_cases?: number;
count_in_progress_cases?: number;
count_closed_cases?: number;
}
export interface UseCasesByStatusProps {
skip?: boolean;
}
export interface UseCasesByStatusResults {
closed: number;
inProgress: number;
isLoading: boolean;
open: number;
totalCounts: number;
updatedAt: number;
}
export const useCasesByStatus = ({ skip = false }) => {
const {
services: { cases },
} = useKibana();
const { to, from } = useGlobalTime();
const [updatedAt, setUpdatedAt] = useState(Date.now());
const [isLoading, setIsLoading] = useState(true);
const [casesCounts, setCasesCounts] = useState<CasesCounts | null>(null);
useEffect(() => {
let isSubscribed = true;
const abortCtrl = new AbortController();
const fetchCases = async () => {
try {
const casesResponse = await cases.api.cases.getAllCasesMetrics({
from,
to,
owner: APP_ID,
});
if (isSubscribed) {
setCasesCounts(casesResponse);
}
} catch (error) {
if (isSubscribed) {
setCasesCounts({});
}
}
if (isSubscribed) {
setIsLoading(false);
setUpdatedAt(Date.now());
}
};
if (!skip) {
fetchCases();
}
if (skip) {
setIsLoading(false);
isSubscribed = false;
abortCtrl.abort();
}
return () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [cases.api.cases, from, skip, to]);
return {
closed: casesCounts?.count_closed_cases ?? 0,
inProgress: casesCounts?.count_in_progress_cases ?? 0,
isLoading,
open: casesCounts?.count_open_cases ?? 0,
totalCounts:
(casesCounts?.count_closed_cases ?? 0) +
(casesCounts?.count_in_progress_cases ?? 0) +
(casesCounts?.count_open_cases ?? 0),
updatedAt,
};
};

View file

@ -31,26 +31,23 @@ export const STATUS_LOW_LABEL = i18n.translate(
defaultMessage: 'Low',
}
);
export const STATUS_OPEN = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.open',
{
defaultMessage: 'Open',
}
);
export const STATUS_ACKNOWLEDGED = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.acknowledged',
'xpack.securitySolution.detectionResponse.alertsByStatus.status.acknowledged',
{
defaultMessage: 'Acknowledged',
}
);
export const STATUS_OPEN = i18n.translate('xpack.securitySolution.detectionResponse.status.open', {
defaultMessage: 'Open',
});
export const STATUS_CLOSED = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.closed',
'xpack.securitySolution.detectionResponse.status.closed',
{
defaultMessage: 'Closed',
}
);
export const STATUS_IN_PROGRESS = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.inProgress',
'xpack.securitySolution.detectionResponse.status.inProgress',
{
defaultMessage: 'In progress',
}
@ -69,6 +66,21 @@ export const UPDATING = i18n.translate('xpack.securitySolution.detectionResponse
export const UPDATED = i18n.translate('xpack.securitySolution.detectionResponse.updated', {
defaultMessage: 'Updated',
});
export const CASES = (totalCases: number) =>
i18n.translate('xpack.securitySolution.detectionResponse.casesByStatus.totalCases', {
values: { totalCases },
defaultMessage: 'total {totalCases, plural, =1 {case} other {cases}}',
});
export const CASES_BY_STATUS_SECTION_TITLE = i18n.translate(
'xpack.securitySolution.detectionResponse.casesByStatusSectionTitle',
{
defaultMessage: 'Cases',
}
);
export const VIEW_CASES = i18n.translate('xpack.securitySolution.detectionResponse.viewCases', {
defaultMessage: 'View cases',
});
export const RULE_ALERTS_SECTION_TITLE = i18n.translate(
'xpack.securitySolution.detectionResponse.ruleAlertsSectionTitle',

View file

@ -14,7 +14,14 @@ import { TestProviders } from '../../common/mock';
jest.mock('../components/detection_response/rule_alerts_table', () => ({
RuleAlertsTable: () => <div data-test-subj="mock_RuleAlertsTable" />,
}));
// TODO: add all sections mocks
jest.mock('../components/detection_response/alerts_by_status', () => ({
AlertsByStatus: () => <div data-test-subj="mock_AlertsByStatus" />,
}));
jest.mock('../components/detection_response/cases_by_status', () => ({
CasesByStatus: () => <div data-test-subj="mock_CasesByStatus" />,
}));
jest.mock('../../common/components/search_bar', () => ({
SiemSearchBar: () => <div data-test-subj="mock_globalDatePicker" />,
@ -128,9 +135,12 @@ describe('DetectionResponse', () => {
);
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
// TODO: assert other alert sections are not in the document
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
// TODO: assert cases sections are in the document
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
});
it('should not render alerts data sections if user has not kibana read permission', () => {
@ -148,9 +158,13 @@ describe('DetectionResponse', () => {
);
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
// TODO: assert all alert sections are not in the document
expect(result.queryByTestId('mock_RuleAlertsTable')).not.toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).not.toBeInTheDocument();
// TODO: assert all cases sections are in the document
expect(result.queryByTestId('mock_CasesByStatus')).toBeInTheDocument();
});
it('should not render cases data sections if user has not cases read permission', () => {
@ -165,9 +179,11 @@ describe('DetectionResponse', () => {
);
expect(result.queryByTestId('detectionResponsePage')).toBeInTheDocument();
expect(result.queryByTestId('mock_RuleAlertsTable')).toBeInTheDocument();
// TODO: assert all alert sections are in the document
expect(result.queryByTestId('mock_RuleAlertsTable')).toBeInTheDocument();
expect(result.queryByTestId('mock_AlertsByStatus')).toBeInTheDocument();
// TODO: assert all cases sections are not in the document
expect(result.queryByTestId('mock_CasesByStatus')).not.toBeInTheDocument();
});
it('should render page permissions message if user has any read permission', () => {

View file

@ -20,6 +20,7 @@ import { LandingPageComponent } from '../../common/components/landing_page';
import { RuleAlertsTable } from '../components/detection_response/rule_alerts_table';
import * as i18n from './translations';
import { EmptyPage } from '../../common/components/empty_page';
import { CasesByStatus } from '../components/detection_response/cases_by_status';
import { AlertsByStatus } from '../components/detection_response/alerts_by_status';
const NoPrivilegePage: React.FC = () => {
@ -75,7 +76,11 @@ const DetectionResponseComponent = () => {
<AlertsByStatus signalIndexName={signalIndexName} />
</EuiFlexItem>
)}
{canReadCases && <EuiFlexItem>{'[cases chart]'}</EuiFlexItem>}
{canReadCases && (
<EuiFlexItem>
<CasesByStatus />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>