[Security Solution][Case] Add in-progress status to case (#84321)

This commit is contained in:
Christos Nasikas 2020-12-04 21:36:23 +02:00 committed by GitHub
parent 554ee9ebf9
commit fcccb016f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 971 additions and 429 deletions

View file

@ -15,12 +15,24 @@ import { CaseConnectorRt, ESCaseConnector, ConnectorPartialFieldsRt } from '../c
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
export { ActionTypeExecutorResult } from '../../../../actions/server/types';
const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]);
export enum CaseStatuses {
open = 'open',
'in-progress' = 'in-progress',
closed = 'closed',
}
const CaseStatusRt = rt.union([
rt.literal(CaseStatuses.open),
rt.literal(CaseStatuses['in-progress']),
rt.literal(CaseStatuses.closed),
]);
export const caseStatuses = Object.values(CaseStatuses);
const CaseBasicRt = rt.type({
connector: CaseConnectorRt,
description: rt.string,
status: StatusRt,
status: CaseStatusRt,
tags: rt.array(rt.string),
title: rt.string,
});
@ -68,7 +80,7 @@ export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt;
export const CasesFindRequestRt = rt.partial({
tags: rt.union([rt.array(rt.string), rt.string]),
status: StatusRt,
status: CaseStatusRt,
reporters: rt.union([rt.array(rt.string), rt.string]),
defaultSearchOperator: rt.union([rt.literal('AND'), rt.literal('OR')]),
fields: rt.array(rt.string),
@ -177,7 +189,6 @@ export type CasesResponse = rt.TypeOf<typeof CasesResponseRt>;
export type CasesFindResponse = rt.TypeOf<typeof CasesFindResponseRt>;
export type CasePatchRequest = rt.TypeOf<typeof CasePatchRequestRt>;
export type CasesPatchRequest = rt.TypeOf<typeof CasesPatchRequestRt>;
export type Status = rt.TypeOf<typeof StatusRt>;
export type CaseExternalServiceRequest = rt.TypeOf<typeof CaseExternalServiceRequestRt>;
export type ServiceConnectorCaseParams = rt.TypeOf<typeof ServiceConnectorCaseParamsRt>;
export type ServiceConnectorCaseResponse = rt.TypeOf<typeof ServiceConnectorCaseResponseRt>;

View file

@ -8,6 +8,7 @@ import * as rt from 'io-ts';
export const CasesStatusResponseRt = rt.type({
count_open_cases: rt.number,
count_in_progress_cases: rt.number,
count_closed_cases: rt.number,
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorTypes, CasePostRequest } from '../../../common/api';
import { ConnectorTypes, CasePostRequest, CaseStatuses } from '../../../common/api';
import {
createMockSavedObjectsRepository,
@ -60,7 +60,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@ -126,7 +126,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@ -169,7 +169,7 @@ describe('create', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,
@ -316,7 +316,7 @@ describe('create', () => {
title: 'a title',
description: 'This is a brand new case of a bad meanie defacing data',
tags: ['defacement'],
status: 'closed',
status: CaseStatuses.closed,
connector: {
id: 'none',
name: 'none',

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ConnectorTypes, CasesPatchRequest } from '../../../common/api';
import { ConnectorTypes, CasesPatchRequest, CaseStatuses } from '../../../common/api';
import {
createMockSavedObjectsRepository,
mockCaseNoConnectorId,
@ -27,7 +27,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
status: 'closed' as const,
status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@ -56,7 +56,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
status: 'closed',
status: CaseStatuses.closed,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@ -79,8 +79,8 @@ describe('update', () => {
username: 'awesome',
},
action_field: ['status'],
new_value: 'closed',
old_value: 'open',
new_value: CaseStatuses.closed,
old_value: CaseStatuses.open,
},
references: [
{
@ -98,7 +98,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
status: 'open' as const,
status: CaseStatuses.open,
version: 'WzAsMV0=',
},
],
@ -106,7 +106,10 @@ describe('update', () => {
const savedObjectsClient = createMockSavedObjectsRepository({
caseSavedObject: [
{ ...mockCases[0], attributes: { ...mockCases[0].attributes, status: 'closed' } },
{
...mockCases[0],
attributes: { ...mockCases[0].attributes, status: CaseStatuses.closed },
},
...mockCases.slice(1),
],
});
@ -130,7 +133,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@ -146,7 +149,7 @@ describe('update', () => {
cases: [
{
id: 'mock-no-connector_id',
status: 'closed' as const,
status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@ -177,7 +180,7 @@ describe('update', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'closed',
status: CaseStatuses.closed,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
@ -231,7 +234,7 @@ describe('update', () => {
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
title: 'Another bad one',
status: 'open',
status: CaseStatuses.open,
tags: ['LOLBins'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@ -314,7 +317,7 @@ describe('update', () => {
cases: [
{
id: 'mock-id-1',
status: 'open' as const,
status: CaseStatuses.open,
version: 'WzAsMV0=',
},
],

View file

@ -19,6 +19,7 @@ import {
ESCasePatchRequest,
CasePatchRequest,
CasesResponse,
CaseStatuses,
} from '../../../common/api';
import { buildCaseUserActions } from '../../services/user_actions/helpers';
import {
@ -98,12 +99,15 @@ export const update = ({
cases: updateFilterCases.map((thisCase) => {
const { id: caseId, version, ...updateCaseAttributes } = thisCase;
let closedInfo = {};
if (updateCaseAttributes.status && updateCaseAttributes.status === 'closed') {
if (updateCaseAttributes.status && updateCaseAttributes.status === CaseStatuses.closed) {
closedInfo = {
closed_at: updatedDt,
closed_by: { email, full_name, username },
};
} else if (updateCaseAttributes.status && updateCaseAttributes.status === 'open') {
} else if (
updateCaseAttributes.status &&
updateCaseAttributes.status === CaseStatuses.open
) {
closedInfo = {
closed_at: null,
closed_by: null,

View file

@ -9,7 +9,7 @@ import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsMock } from '../../../../actions/server/mocks';
import { validateParams } from '../../../../actions/server/lib';
import { ConnectorTypes, CommentType } from '../../../common/api';
import { ConnectorTypes, CommentType, CaseStatuses } from '../../../common/api';
import {
createCaseServiceMock,
createConfigureServiceMock,
@ -785,7 +785,7 @@ describe('case connector', () => {
tags: ['case', 'connector'],
description: 'Yo fields!!',
external_service: null,
status: 'open' as const,
status: CaseStatuses.open,
updated_at: null,
updated_by: null,
version: 'WzksMV0=',
@ -868,7 +868,7 @@ describe('case connector', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
status: 'open' as const,
status: CaseStatuses.open,
tags: ['defacement'],
title: 'Update title',
totalComment: 0,
@ -937,7 +937,7 @@ describe('case connector', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open' as const,
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: null,
updated_by: null,

View file

@ -11,6 +11,7 @@ import {
ESCaseAttributes,
ConnectorTypes,
CommentType,
CaseStatuses,
} from '../../../../common/api';
export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
@ -35,7 +36,7 @@ export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@ -69,7 +70,7 @@ export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
description: 'Oh no, a bad meanie destroying data!',
external_service: null,
title: 'Damaging Data Destruction Detected',
status: 'open',
status: CaseStatuses.open,
tags: ['Data Destruction'],
updated_at: '2019-11-25T22:32:00.900Z',
updated_by: {
@ -107,7 +108,7 @@ export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
title: 'Another bad one',
status: 'open',
status: CaseStatuses.open,
tags: ['LOLBins'],
updated_at: '2019-11-25T22:32:17.947Z',
updated_by: {
@ -148,7 +149,7 @@ export const mockCases: Array<SavedObject<ESCaseAttributes>> = [
},
description: 'Oh no, a bad meanie going LOLBins all over the place!',
external_service: null,
status: 'closed',
status: CaseStatuses.closed,
title: 'Another bad one',
tags: ['LOLBins'],
updated_at: '2019-11-25T22:32:17.947Z',
@ -179,7 +180,7 @@ export const mockCaseNoConnectorId: SavedObject<Partial<ESCaseAttributes>> = {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {

View file

@ -38,6 +38,10 @@ describe('FIND all cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.cases).toHaveLength(4);
// mockSavedObjectsRepository do not support filters and returns all cases every time.
expect(response.payload.count_open_cases).toEqual(4);
expect(response.payload.count_closed_cases).toEqual(4);
expect(response.payload.count_in_progress_cases).toEqual(4);
});
it(`has proper connector id on cases with configured connector`, async () => {

View file

@ -11,7 +11,13 @@ import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { isEmpty } from 'lodash';
import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api';
import {
CasesFindResponseRt,
CasesFindRequestRt,
throwErrors,
CaseStatuses,
caseStatuses,
} from '../../../../common/api';
import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils';
import { RouteDeps, TotalCommentByCase } from '../types';
import { CASE_SAVED_OBJECT } from '../../../saved_object_types';
@ -20,7 +26,7 @@ import { CASES_URL } from '../../../../common/constants';
const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string =>
filters?.filter((i) => i !== '').join(` ${operator} `);
const getStatusFilter = (status: 'open' | 'closed', appendFilter?: string) =>
const getStatusFilter = (status: CaseStatuses, appendFilter?: string) =>
`${CASE_SAVED_OBJECT}.attributes.status: ${status}${
!isEmpty(appendFilter) ? ` AND ${appendFilter}` : ''
}`;
@ -75,30 +81,21 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
client,
};
const argsOpenCases = {
const statusArgs = caseStatuses.map((caseStatus) => ({
client,
options: {
fields: [],
page: 1,
perPage: 1,
filter: getStatusFilter('open', myFilters),
filter: getStatusFilter(caseStatus, myFilters),
},
};
}));
const argsClosedCases = {
client,
options: {
fields: [],
page: 1,
perPage: 1,
filter: getStatusFilter('closed', myFilters),
},
};
const [cases, openCases, closesCases] = await Promise.all([
const [cases, openCases, inProgressCases, closedCases] = await Promise.all([
caseService.findCases(args),
caseService.findCases(argsOpenCases),
caseService.findCases(argsClosedCases),
...statusArgs.map((arg) => caseService.findCases(arg)),
]);
const totalCommentsFindByCases = await Promise.all(
cases.saved_objects.map((c) =>
caseService.getAllCaseComments({
@ -133,7 +130,8 @@ export function initFindCasesApi({ caseService, caseConfigureService, router }:
transformCases(
cases,
openCases.total ?? 0,
closesCases.total ?? 0,
inProgressCases.total ?? 0,
closedCases.total ?? 0,
totalCommentsByCases
)
),

View file

@ -16,7 +16,7 @@ import {
} from '../__fixtures__';
import { initPatchCasesApi } from './patch_cases';
import { mockCaseConfigure, mockCaseNoConnectorId } from '../__fixtures__/mock_saved_objects';
import { ConnectorTypes } from '../../../../common/api/connectors';
import { ConnectorTypes, CaseStatuses } from '../../../../common/api';
describe('PATCH cases', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -36,7 +36,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
status: 'closed',
status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@ -67,7 +67,7 @@ describe('PATCH cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
status: 'closed',
status: CaseStatuses.closed,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
@ -86,7 +86,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-4',
status: 'open',
status: CaseStatuses.open,
version: 'WzUsMV0=',
},
],
@ -118,7 +118,7 @@ describe('PATCH cases', () => {
description: 'Oh no, a bad meanie going LOLBins all over the place!',
id: 'mock-id-4',
external_service: null,
status: 'open',
status: CaseStatuses.open,
tags: ['LOLBins'],
title: 'Another bad one',
totalComment: 0,
@ -129,6 +129,56 @@ describe('PATCH cases', () => {
]);
});
it(`Change case to in-progress`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
method: 'patch',
body: {
cases: [
{
id: 'mock-id-1',
status: CaseStatuses['in-progress'],
version: 'WzAsMV0=',
},
],
},
});
const theContext = await createRouteContext(
createMockSavedObjectsRepository({
caseSavedObject: mockCases,
})
);
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload).toEqual([
{
closed_at: null,
closed_by: null,
comments: [],
connector: {
id: 'none',
name: 'none',
type: ConnectorTypes.none,
fields: null,
},
created_at: '2019-11-25T21:54:48.952Z',
created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' },
description: 'This is a brand new case of a bad meanie defacing data',
id: 'mock-id-1',
external_service: null,
status: CaseStatuses['in-progress'],
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' },
version: 'WzE3LDFd',
},
]);
});
it(`Patches a case without a connector.id`, async () => {
const request = httpServerMock.createKibanaRequest({
path: '/api/cases',
@ -137,7 +187,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-no-connector_id',
status: 'closed',
status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],
@ -163,7 +213,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-3',
status: 'closed',
status: CaseStatuses.closed,
version: 'WzUsMV0=',
},
],
@ -225,7 +275,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
case: { status: 'closed' },
case: { status: CaseStatuses.closed },
version: 'badv=',
},
],
@ -250,7 +300,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-1',
case: { status: 'open' },
case: { status: CaseStatuses.open },
version: 'WzAsMV0=',
},
],
@ -276,7 +326,7 @@ describe('PATCH cases', () => {
cases: [
{
id: 'mock-id-does-not-exist',
status: 'closed',
status: CaseStatuses.closed,
version: 'WzAsMV0=',
},
],

View file

@ -16,7 +16,7 @@ import {
import { initPostCaseApi } from './post_case';
import { CASES_URL } from '../../../../common/constants';
import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects';
import { ConnectorTypes } from '../../../../common/api/connectors';
import { ConnectorTypes, CaseStatuses } from '../../../../common/api';
describe('POST cases', () => {
let routeHandler: RequestHandler<any, any, any>;
@ -54,6 +54,7 @@ describe('POST cases', () => {
const response = await routeHandler(theContext, request, kibanaResponseFactory);
expect(response.status).toEqual(200);
expect(response.payload.id).toEqual('mock-it');
expect(response.payload.status).toEqual('open');
expect(response.payload.created_by.username).toEqual('awesome');
expect(response.payload.connector).toEqual({
id: 'none',
@ -104,7 +105,7 @@ describe('POST cases', () => {
body: {
description: 'This is a brand new case of a bad meanie defacing data',
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
connector: null,
},
@ -191,7 +192,7 @@ describe('POST cases', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
id: 'mock-it',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
title: 'Super Bad Security Issue',
totalComment: 0,

View file

@ -18,7 +18,12 @@ import {
getCommentContextFromAttributes,
} from '../utils';
import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api';
import {
CaseExternalServiceRequestRt,
CaseResponseRt,
throwErrors,
CaseStatuses,
} from '../../../../common/api';
import { buildCaseUserActionItem } from '../../../services/user_actions/helpers';
import { RouteDeps } from '../types';
import { CASE_DETAILS_URL } from '../../../../common/constants';
@ -77,7 +82,7 @@ export function initPushCaseUserActionApi({
actionsClient.getAll(),
]);
if (myCase.attributes.status === 'closed') {
if (myCase.attributes.status === CaseStatuses.closed) {
throw Boom.conflict(
`This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.`
);
@ -117,7 +122,7 @@ export function initPushCaseUserActionApi({
...(myCaseConfigure.total > 0 &&
myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing'
? {
status: 'closed',
status: CaseStatuses.closed,
closed_at: pushedDate,
closed_by: { email, full_name, username },
}
@ -153,7 +158,7 @@ export function initPushCaseUserActionApi({
actionBy: { username, full_name, email },
caseId,
fields: ['status'],
newValue: 'closed',
newValue: CaseStatuses.closed,
oldValue: myCase.attributes.status,
}),
]

View file

@ -7,7 +7,7 @@
import { RouteDeps } from '../../types';
import { wrapError } from '../../utils';
import { CasesStatusResponseRt } from '../../../../../common/api';
import { CasesStatusResponseRt, caseStatuses } from '../../../../../common/api';
import { CASE_SAVED_OBJECT } from '../../../../saved_object_types';
import { CASE_STATUS_URL } from '../../../../../common/constants';
@ -20,34 +20,24 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) {
async (context, request, response) => {
try {
const client = context.core.savedObjects.client;
const argsOpenCases = {
const args = caseStatuses.map((status) => ({
client,
options: {
fields: [],
page: 1,
perPage: 1,
filter: `${CASE_SAVED_OBJECT}.attributes.status: open`,
filter: `${CASE_SAVED_OBJECT}.attributes.status: ${status}`,
},
};
}));
const argsClosedCases = {
client,
options: {
fields: [],
page: 1,
perPage: 1,
filter: `${CASE_SAVED_OBJECT}.attributes.status: closed`,
},
};
const [openCases, closesCases] = await Promise.all([
caseService.findCases(argsOpenCases),
caseService.findCases(argsClosedCases),
]);
const [openCases, inProgressCases, closesCases] = await Promise.all(
args.map((arg) => caseService.findCases(arg))
);
return response.ok({
body: CasesStatusResponseRt.encode({
count_open_cases: openCases.total,
count_in_progress_cases: inProgressCases.total,
count_closed_cases: closesCases.total,
}),
});

View file

@ -23,7 +23,7 @@ import {
mockCaseComments,
mockCaseNoConnectorId,
} from './__fixtures__/mock_saved_objects';
import { ConnectorTypes, ESCaseConnector, CommentType } from '../../../common/api';
import { ConnectorTypes, ESCaseConnector, CommentType, CaseStatuses } from '../../../common/api';
describe('Utils', () => {
describe('transformNewCase', () => {
@ -57,7 +57,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: 'elastic@elastic.co', full_name: 'Elastic', username: 'elastic' },
external_service: null,
status: 'open',
status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@ -80,7 +80,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: undefined, full_name: undefined, username: undefined },
external_service: null,
status: 'open',
status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@ -106,7 +106,7 @@ describe('Utils', () => {
created_at: '2020-04-09T09:43:51.778Z',
created_by: { email: null, full_name: null, username: null },
external_service: null,
status: 'open',
status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@ -247,6 +247,7 @@ describe('Utils', () => {
},
2,
2,
2,
extraCaseData
);
expect(res).toEqual({
@ -259,6 +260,7 @@ describe('Utils', () => {
),
count_open_cases: 2,
count_closed_cases: 2,
count_in_progress_cases: 2,
});
});
});
@ -289,7 +291,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@ -328,7 +330,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@ -374,7 +376,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {
@ -484,7 +486,7 @@ describe('Utils', () => {
description: 'This is a brand new case of a bad meanie defacing data',
external_service: null,
title: 'Super Bad Security Issue',
status: 'open',
status: CaseStatuses.open,
tags: ['defacement'],
updated_at: '2019-11-25T21:54:48.952Z',
updated_by: {

View file

@ -33,6 +33,7 @@ import {
CommentType,
excess,
throwErrors,
CaseStatuses,
} from '../../../common/api';
import { transformESConnectorToCaseConnector } from './cases/helpers';
@ -61,7 +62,7 @@ export const transformNewCase = ({
created_at: createdDate,
created_by: { email, full_name, username },
external_service: null,
status: 'open',
status: CaseStatuses.open,
updated_at: null,
updated_by: null,
});
@ -103,6 +104,7 @@ export function wrapError(error: any): CustomHttpResponseOptions<ResponseError>
export const transformCases = (
cases: SavedObjectsFindResponse<ESCaseAttributes>,
countOpenCases: number,
countInProgressCases: number,
countClosedCases: number,
totalCommentByCase: TotalCommentByCase[]
): CasesFindResponse => ({
@ -111,6 +113,7 @@ export const transformCases = (
total: cases.total,
cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase),
count_open_cases: countOpenCases,
count_in_progress_cases: countInProgressCases,
count_closed_cases: countClosedCases,
});

View file

@ -8,10 +8,10 @@ import { case1 } from '../objects/case';
import {
ALL_CASES_CLOSE_ACTION,
ALL_CASES_CLOSED_CASES_COUNT,
ALL_CASES_CLOSED_CASES_STATS,
ALL_CASES_COMMENTS_COUNT,
ALL_CASES_DELETE_ACTION,
ALL_CASES_IN_PROGRESS_CASES_STATS,
ALL_CASES_NAME,
ALL_CASES_OPEN_CASES_COUNT,
ALL_CASES_OPEN_CASES_STATS,
@ -70,8 +70,8 @@ describe('Cases', () => {
cy.get(ALL_CASES_PAGE_TITLE).should('have.text', 'Cases');
cy.get(ALL_CASES_OPEN_CASES_STATS).should('have.text', 'Open cases1');
cy.get(ALL_CASES_CLOSED_CASES_STATS).should('have.text', 'Closed cases0');
cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open cases (1)');
cy.get(ALL_CASES_CLOSED_CASES_COUNT).should('have.text', 'Closed cases (0)');
cy.get(ALL_CASES_IN_PROGRESS_CASES_STATS).should('have.text', 'In progress cases0');
cy.get(ALL_CASES_OPEN_CASES_COUNT).should('have.text', 'Open (1)');
cy.get(ALL_CASES_REPORTERS_COUNT).should('have.text', 'Reporter1');
cy.get(ALL_CASES_TAGS_COUNT).should('have.text', 'Tags2');
cy.get(ALL_CASES_NAME).should('have.text', case1.name);
@ -89,7 +89,7 @@ describe('Cases', () => {
const expectedTags = case1.tags.join('');
cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', case1.name);
cy.get(CASE_DETAILS_STATUS).should('have.text', 'open');
cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open');
cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', case1.reporter);
cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description');
cy.get(CASE_DETAILS_DESCRIPTION).should(

View file

@ -10,8 +10,6 @@ export const ALL_CASES_CASE = (id: string) => {
export const ALL_CASES_CLOSE_ACTION = '[data-test-subj="action-close"]';
export const ALL_CASES_CLOSED_CASES_COUNT = '[data-test-subj="closed-case-count"]';
export const ALL_CASES_CLOSED_CASES_STATS = '[data-test-subj="closedStatsHeader"]';
export const ALL_CASES_COMMENTS_COUNT = '[data-test-subj="case-table-column-commentCount"]';
@ -22,9 +20,11 @@ export const ALL_CASES_CREATE_NEW_CASE_TABLE_BTN = '[data-test-subj="cases-table
export const ALL_CASES_DELETE_ACTION = '[data-test-subj="action-delete"]';
export const ALL_CASES_IN_PROGRESS_CASES_STATS = '[data-test-subj="inProgressStatsHeader"]';
export const ALL_CASES_NAME = '[data-test-subj="case-details-link"]';
export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="open-case-count"]';
export const ALL_CASES_OPEN_CASES_COUNT = '[data-test-subj="case-status-filter"]';
export const ALL_CASES_OPEN_CASES_STATS = '[data-test-subj="openStatsHeader"]';

View file

@ -14,7 +14,7 @@ export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]';
export const CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN =
'[data-test-subj="push-to-external-service"]';
export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]';
export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"]';
export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]';

View file

@ -3,12 +3,14 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { Dispatch } from 'react';
import { Case } from '../../containers/types';
import * as i18n from './translations';
import { Dispatch } from 'react';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../../../case/common/api';
import { Case } from '../../containers/types';
import { UpdateCase } from '../../containers/use_get_cases';
import * as i18n from './translations';
interface GetActions {
caseStatus: string;
@ -29,7 +31,7 @@ export const getActions = ({
type: 'icon',
'data-test-subj': 'action-delete',
},
caseStatus === 'open'
caseStatus === CaseStatuses.open
? {
description: i18n.CLOSE_CASE,
icon: 'folderCheck',
@ -37,7 +39,7 @@ export const getActions = ({
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: 'closed',
updateValue: CaseStatuses.closed,
caseId: theCase.id,
version: theCase.version,
}),
@ -51,7 +53,7 @@ export const getActions = ({
onClick: (theCase: Case) =>
dispatchUpdate({
updateKey: 'status',
updateValue: 'open',
updateValue: CaseStatuses.open,
caseId: theCase.id,
version: theCase.version,
}),

View file

@ -3,6 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import {
EuiAvatar,
@ -16,6 +17,8 @@ import {
} from '@elastic/eui';
import styled from 'styled-components';
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
import { CaseStatuses } from '../../../../../case/common/api';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { Case } from '../../containers/types';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
@ -59,7 +62,7 @@ export const getCasesColumns = (
) : (
<span>{theCase.title}</span>
);
return theCase.status === 'open' ? (
return theCase.status !== CaseStatuses.closed ? (
caseDetailsLinkComponent
) : (
<>
@ -127,7 +130,7 @@ export const getCasesColumns = (
? renderStringField(`${totalComment}`, `case-table-column-commentCount`)
: getEmptyTagValue(),
},
filterStatus === 'open'
filterStatus === CaseStatuses.open
? {
field: 'createdAt',
name: i18n.OPENED_ON,

View file

@ -14,6 +14,7 @@ import { TestProviders } from '../../../common/mock';
import { useGetCasesMockState } from '../../containers/mock';
import * as i18n from './translations';
import { CaseStatuses } from '../../../../../case/common/api';
import { useKibana } from '../../../common/lib/kibana';
import { getEmptyTagValue } from '../../../common/components/empty_value';
import { useDeleteCases } from '../../containers/use_delete_cases';
@ -159,7 +160,7 @@ describe('AllCases', () => {
expect(column.find('span').text()).toEqual(emptyTag);
};
await waitFor(() => {
getCasesColumns([], 'open', false).map(
getCasesColumns([], CaseStatuses.open, false).map(
(i, key) => i.name != null && checkIt(`${i.name}`, key)
);
});
@ -175,7 +176,9 @@ describe('AllCases', () => {
const checkIt = (columnName: string) => {
expect(columnName).not.toEqual(i18n.ACTIONS);
};
getCasesColumns([], 'open', true).map((i, key) => i.name != null && checkIt(`${i.name}`));
getCasesColumns([], CaseStatuses.open, true).map(
(i, key) => i.name != null && checkIt(`${i.name}`)
);
expect(wrapper.find(`a[data-test-subj="case-details-link"]`).exists()).toBeFalsy();
});
});
@ -208,7 +211,7 @@ describe('AllCases', () => {
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: 'closed',
updateValue: CaseStatuses.closed,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
@ -217,7 +220,7 @@ describe('AllCases', () => {
it('opens case when row action icon clicked', async () => {
useGetCasesMock.mockReturnValue({
...defaultGetCases,
filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' },
filterOptions: { ...defaultGetCases.filterOptions, status: CaseStatuses.closed },
});
const wrapper = mount(
@ -231,7 +234,7 @@ describe('AllCases', () => {
expect(dispatchUpdateCaseProperty).toBeCalledWith({
caseId: firstCase.id,
updateKey: 'status',
updateValue: 'open',
updateValue: CaseStatuses.open,
refetchCasesStatus: fetchCasesStatus,
version: firstCase.version,
});
@ -288,7 +291,7 @@ describe('AllCases', () => {
await waitFor(() => {
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-close-button"]').first().simulate('click');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.closed);
});
});
it('Bulk open status update', async () => {
@ -297,7 +300,7 @@ describe('AllCases', () => {
selectedCases: useGetCasesMockState.data.cases,
filterOptions: {
...defaultGetCases.filterOptions,
status: 'closed',
status: CaseStatuses.closed,
},
});
@ -309,7 +312,7 @@ describe('AllCases', () => {
await waitFor(() => {
wrapper.find('[data-test-subj="case-table-bulk-actions"] button').first().simulate('click');
wrapper.find('[data-test-subj="cases-bulk-open-button"]').first().simulate('click');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open');
expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, CaseStatuses.open);
});
});
it('isDeleted is true, refetch', async () => {

View file

@ -19,6 +19,7 @@ import { isEmpty, memoize } from 'lodash/fp';
import styled, { css } from 'styled-components';
import * as i18n from './translations';
import { CaseStatuses } from '../../../../../case/common/api';
import { getCasesColumns } from './columns';
import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types';
import { useGetCases, UpdateCase } from '../../containers/use_get_cases';
@ -37,7 +38,6 @@ import { getCreateCaseUrl, useFormatUrl } from '../../../common/components/link_
import { getBulkItems } from '../bulk_actions';
import { CaseHeaderPage } from '../case_header_page';
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
import { OpenClosedStats } from '../open_closed_stats';
import { getActions } from './actions';
import { CasesTableFilters } from './table_filters';
import { useUpdateCases } from '../../containers/use_bulk_update_case';
@ -50,6 +50,7 @@ import { LinkButton } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
import { useKibana } from '../../../common/lib/kibana';
import { APP_ID } from '../../../../common/constants';
import { Stats } from '../status';
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
@ -91,8 +92,9 @@ export const AllCases = React.memo<AllCasesProps>(
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case);
const { actionLicense } = useGetActionLicense();
const {
countClosedCases,
countOpenCases,
countInProgressCases,
countClosedCases,
isLoading: isCasesStatusLoading,
fetchCasesStatus,
} = useGetCasesStatus();
@ -291,10 +293,15 @@ export const AllCases = React.memo<AllCasesProps>(
const onFilterChangedCallback = useCallback(
(newFilterOptions: Partial<FilterOptions>) => {
if (newFilterOptions.status && newFilterOptions.status === 'closed') {
if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.closed) {
setQueryParams({ sortField: SortFieldCase.closedAt });
} else if (newFilterOptions.status && newFilterOptions.status === 'open') {
} else if (newFilterOptions.status && newFilterOptions.status === CaseStatuses.open) {
setQueryParams({ sortField: SortFieldCase.createdAt });
} else if (
newFilterOptions.status &&
newFilterOptions.status === CaseStatuses['in-progress']
) {
setQueryParams({ sortField: SortFieldCase.updatedAt });
}
setFilters(newFilterOptions);
refreshCases(false);
@ -375,18 +382,26 @@ export const AllCases = React.memo<AllCasesProps>(
data-test-subj="all-cases-header"
>
<EuiFlexItem grow={false}>
<OpenClosedStats
<Stats
dataTestSubj="openStatsHeader"
caseCount={countOpenCases}
caseStatus={'open'}
caseStatus={CaseStatuses.open}
isLoading={isCasesStatusLoading}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Stats
dataTestSubj="inProgressStatsHeader"
caseCount={countInProgressCases}
caseStatus={CaseStatuses['in-progress']}
isLoading={isCasesStatusLoading}
/>
</EuiFlexItem>
<FlexItemDivider grow={false}>
<OpenClosedStats
<Stats
dataTestSubj="closedStatsHeader"
caseCount={countClosedCases}
caseStatus={'closed'}
caseStatus={CaseStatuses.closed}
isLoading={isCasesStatusLoading}
/>
</FlexItemDivider>
@ -422,6 +437,7 @@ export const AllCases = React.memo<AllCasesProps>(
<CasesTableFilters
countClosedCases={data.countClosedCases}
countOpenCases={data.countOpenCases}
countInProgressCases={data.countInProgressCases}
onFilterChanged={onFilterChangedCallback}
initial={{
search: filterOptions.search,

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo } from 'react';
import { EuiSuperSelect, EuiSuperSelectOption, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { Status, statuses } from '../status';
interface Props {
stats: Record<CaseStatuses, number>;
selectedStatus: CaseStatuses;
onStatusChanged: (status: CaseStatuses) => void;
}
const StatusFilterComponent: React.FC<Props> = ({ stats, selectedStatus, onStatusChanged }) => {
const caseStatuses = Object.keys(statuses) as CaseStatuses[];
const options: Array<EuiSuperSelectOption<CaseStatuses>> = caseStatuses.map((status) => ({
value: status,
inputDisplay: (
<EuiFlexGroup gutterSize="xs" alignItems={'center'}>
<EuiFlexItem grow={false}>
<Status type={status} />
</EuiFlexItem>
<EuiFlexItem grow={false}>{` (${stats[status]})`}</EuiFlexItem>
</EuiFlexGroup>
),
'data-test-subj': `case-status-filter-${status}`,
}));
return (
<EuiSuperSelect
options={options}
valueOfSelected={selectedStatus}
onChange={onStatusChanged}
data-test-subj="case-status-filter"
/>
);
};
export const StatusFilter = memo(StatusFilterComponent);

View file

@ -7,12 +7,13 @@
import React from 'react';
import { mount } from 'enzyme';
import { CaseStatuses } from '../../../../../case/common/api';
import { CasesTableFilters } from './table_filters';
import { TestProviders } from '../../../common/mock';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { DEFAULT_FILTER_OPTIONS } from '../../containers/use_get_cases';
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');
@ -24,10 +25,12 @@ const setFilterRefetch = jest.fn();
const props = {
countClosedCases: 1234,
countOpenCases: 99,
countInProgressCases: 54,
onFilterChanged,
initial: DEFAULT_FILTER_OPTIONS,
setFilterRefetch,
};
describe('CasesTableFilters ', () => {
beforeEach(() => {
jest.resetAllMocks();
@ -40,19 +43,17 @@ describe('CasesTableFilters ', () => {
fetchReporters,
});
});
it('should render the initial case count', () => {
it('should render the case status filter dropdown', () => {
const wrapper = mount(
<TestProviders>
<CasesTableFilters {...props} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="open-case-count"]`).last().text()).toEqual(
'Open cases (99)'
);
expect(wrapper.find(`[data-test-subj="closed-case-count"]`).last().text()).toEqual(
'Closed cases (1234)'
);
expect(wrapper.find(`[data-test-subj="case-status-filter"]`).first().exists()).toBeTruthy();
});
it('should call onFilterChange when selected tags change', () => {
const wrapper = mount(
<TestProviders>
@ -64,6 +65,7 @@ describe('CasesTableFilters ', () => {
expect(onFilterChanged).toBeCalledWith({ tags: ['coke'] });
});
it('should call onFilterChange when selected reporters change', () => {
const wrapper = mount(
<TestProviders>
@ -79,6 +81,7 @@ describe('CasesTableFilters ', () => {
expect(onFilterChanged).toBeCalledWith({ reporters: [{ username: 'casetester' }] });
});
it('should call onFilterChange when search changes', () => {
const wrapper = mount(
<TestProviders>
@ -92,16 +95,19 @@ describe('CasesTableFilters ', () => {
.simulate('keyup', { key: 'Enter', target: { value: 'My search' } });
expect(onFilterChanged).toBeCalledWith({ search: 'My search' });
});
it('should call onFilterChange when status toggled', () => {
it('should call onFilterChange when changing status', () => {
const wrapper = mount(
<TestProviders>
<CasesTableFilters {...props} />
</TestProviders>
);
wrapper.find(`[data-test-subj="closed-case-count"]`).last().simulate('click');
expect(onFilterChanged).toBeCalledWith({ status: 'closed' });
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
wrapper.find('button[data-test-subj="case-status-filter-closed"]').simulate('click');
expect(onFilterChanged).toBeCalledWith({ status: CaseStatuses.closed });
});
it('should call on load setFilterRefetch', () => {
mount(
<TestProviders>
@ -110,6 +116,7 @@ describe('CasesTableFilters ', () => {
);
expect(setFilterRefetch).toHaveBeenCalled();
});
it('should remove tag from selected tags when tag no longer exists', () => {
const ourProps = {
...props,
@ -125,6 +132,7 @@ describe('CasesTableFilters ', () => {
);
expect(onFilterChanged).toHaveBeenCalledWith({ tags: ['pepsi'] });
});
it('should remove reporter from selected reporters when reporter no longer exists', () => {
const ourProps = {
...props,

View file

@ -4,24 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { isEqual } from 'lodash/fp';
import {
EuiFieldSearch,
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import * as i18n from './translations';
import { EuiFlexGroup, EuiFlexItem, EuiFieldSearch, EuiFilterGroup } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { FilterOptions } from '../../containers/types';
import { useGetTags } from '../../containers/use_get_tags';
import { useGetReporters } from '../../containers/use_get_reporters';
import { FilterPopover } from '../filter_popover';
import { StatusFilter } from './status_filter';
import * as i18n from './translations';
interface CasesTableFiltersProps {
countClosedCases: number | null;
countInProgressCases: number | null;
countOpenCases: number | null;
onFilterChanged: (filterOptions: Partial<FilterOptions>) => void;
initial: FilterOptions;
@ -35,11 +32,12 @@ interface CasesTableFiltersProps {
* @param onFilterChanged change listener to be notified on filter changes
*/
const defaultInitial = { search: '', reporters: [], status: 'open', tags: [] };
const defaultInitial = { search: '', reporters: [], status: CaseStatuses.open, tags: [] };
const CasesTableFiltersComponent = ({
countClosedCases,
countOpenCases,
countInProgressCases,
onFilterChanged,
initial = defaultInitial,
setFilterRefetch,
@ -49,18 +47,20 @@ const CasesTableFiltersComponent = ({
);
const [search, setSearch] = useState(initial.search);
const [selectedTags, setSelectedTags] = useState(initial.tags);
const [showOpenCases, setShowOpenCases] = useState(initial.status === 'open');
const { tags, fetchTags } = useGetTags();
const { reporters, respReporters, fetchReporters } = useGetReporters();
const refetch = useCallback(() => {
fetchTags();
fetchReporters();
}, [fetchReporters, fetchTags]);
useEffect(() => {
if (setFilterRefetch != null) {
setFilterRefetch(refetch);
}
}, [refetch, setFilterRefetch]);
useEffect(() => {
if (selectedReporters.length) {
const newReporters = selectedReporters.filter((r) => reporters.includes(r));
@ -68,6 +68,7 @@ const CasesTableFiltersComponent = ({
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reporters]);
useEffect(() => {
if (selectedTags.length) {
const newTags = selectedTags.filter((t) => tags.includes(t));
@ -100,6 +101,7 @@ const CasesTableFiltersComponent = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[selectedTags]
);
const handleOnSearch = useCallback(
(newSearch) => {
const trimSearch = newSearch.trim();
@ -111,19 +113,26 @@ const CasesTableFiltersComponent = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
[search]
);
const handleToggleFilter = useCallback(
(showOpen) => {
if (showOpen !== showOpenCases) {
setShowOpenCases(showOpen);
onFilterChanged({ status: showOpen ? 'open' : 'closed' });
}
const onStatusChanged = useCallback(
(status: CaseStatuses) => {
onFilterChanged({ status });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[showOpenCases]
[onFilterChanged]
);
const stats = useMemo(
() => ({
[CaseStatuses.open]: countOpenCases ?? 0,
[CaseStatuses['in-progress']]: countInProgressCases ?? 0,
[CaseStatuses.closed]: countClosedCases ?? 0,
}),
[countClosedCases, countInProgressCases, countOpenCases]
);
return (
<EuiFlexGroup gutterSize="m" justifyContent="flexEnd">
<EuiFlexItem grow={true}>
<EuiFlexItem grow={8}>
<EuiFieldSearch
aria-label={i18n.SEARCH_CASES}
data-test-subj="search-cases"
@ -133,26 +142,15 @@ const CasesTableFiltersComponent = ({
onSearch={handleOnSearch}
/>
</EuiFlexItem>
<EuiFlexItem grow={2}>
<StatusFilter
selectedStatus={initial.status}
onStatusChanged={onStatusChanged}
stats={stats}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFilterGroup>
<EuiFilterButton
data-test-subj="open-case-count"
withNext
hasActiveFilters={showOpenCases}
onClick={handleToggleFilter.bind(null, true)}
>
{i18n.OPEN_CASES}
{countOpenCases != null ? ` (${countOpenCases})` : ''}
</EuiFilterButton>
<EuiFilterButton
data-test-subj="closed-case-count"
hasActiveFilters={!showOpenCases}
onClick={handleToggleFilter.bind(null, false)}
>
{i18n.CLOSED_CASES}
{countClosedCases != null ? ` (${countClosedCases})` : ''}
</EuiFilterButton>
<FilterPopover
buttonLabel={i18n.REPORTER}
onSelectedOptionsChanged={handleSelectedReporters}

View file

@ -69,12 +69,6 @@ export const SEARCH_PLACEHOLDER = i18n.translate(
defaultMessage: 'e.g. case name',
}
);
export const OPEN_CASES = i18n.translate('xpack.securitySolution.case.caseTable.openCases', {
defaultMessage: 'Open cases',
});
export const CLOSED_CASES = i18n.translate('xpack.securitySolution.case.caseTable.closedCases', {
defaultMessage: 'Closed cases',
});
export const CLOSED = i18n.translate('xpack.securitySolution.case.caseTable.closed', {
defaultMessage: 'Closed',

View file

@ -6,6 +6,8 @@
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import * as i18n from './translations';
interface GetBulkItems {
@ -24,7 +26,7 @@ export const getBulkItems = ({
updateCaseStatus,
}: GetBulkItems) => {
return [
caseStatus === 'open' ? (
caseStatus === CaseStatuses.open ? (
<EuiContextMenuItem
data-test-subj="cases-bulk-close-button"
disabled={selectedCaseIds.length === 0}
@ -32,7 +34,7 @@ export const getBulkItems = ({
icon="folderCheck"
onClick={() => {
closePopover();
updateCaseStatus('closed');
updateCaseStatus(CaseStatuses.closed);
}}
>
{i18n.BULK_ACTION_CLOSE_SELECTED}
@ -45,7 +47,7 @@ export const getBulkItems = ({
icon="folderExclamation"
onClick={() => {
closePopover();
updateCaseStatus('open');
updateCaseStatus(CaseStatuses.open);
}}
>
{i18n.BULK_ACTION_OPEN_SELECTED}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CaseStatuses } from '../../../../../case/common/api';
import { Case } from '../../containers/types';
import { statuses } from '../status';
export const getStatusDate = (theCase: Case): string | null => {
if (theCase.status === CaseStatuses.open) {
return theCase.createdAt;
} else if (theCase.status === CaseStatuses['in-progress']) {
return theCase.updatedAt;
} else if (theCase.status === CaseStatuses.closed) {
return theCase.closedAt;
}
return null;
};
export const getStatusTitle = (status: CaseStatuses) => statuses[status].actionBar.title;

View file

@ -4,11 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import React, { useMemo } from 'react';
import styled, { css } from 'styled-components';
import {
EuiBadge,
EuiButton,
EuiButtonEmpty,
EuiDescriptionList,
EuiDescriptionListDescription,
@ -16,11 +14,14 @@ import {
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import * as i18n from '../case_view/translations';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { CaseViewActions } from '../case_view/actions';
import { Case } from '../../containers/types';
import { CaseService } from '../../containers/use_get_case_user_actions';
import { StatusContextMenu } from './status_context_menu';
import { getStatusDate, getStatusTitle } from './helpers';
const MyDescriptionList = styled(EuiDescriptionList)`
${({ theme }) => css`
@ -31,58 +32,46 @@ const MyDescriptionList = styled(EuiDescriptionList)`
`}
`;
interface CaseStatusProps {
'data-test-subj': string;
badgeColor: string;
buttonLabel: string;
interface CaseActionBarProps {
caseData: Case;
currentExternalIncident: CaseService | null;
disabled?: boolean;
icon: string;
isLoading: boolean;
isSelected: boolean;
onRefresh: () => void;
status: string;
title: string;
toggleStatusCase: (status: boolean) => void;
value: string | null;
onStatusChanged: (status: CaseStatuses) => void;
}
const CaseStatusComp: React.FC<CaseStatusProps> = ({
'data-test-subj': dataTestSubj,
badgeColor,
buttonLabel,
const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
caseData,
currentExternalIncident,
disabled = false,
icon,
isLoading,
isSelected,
onRefresh,
status,
title,
toggleStatusCase,
value,
onStatusChanged,
}) => {
const handleToggleStatusCase = useCallback(() => {
toggleStatusCase(!isSelected);
}, [toggleStatusCase, isSelected]);
const date = useMemo(() => getStatusDate(caseData), [caseData]);
const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]);
return (
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<MyDescriptionList compressed>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} data-test-subj="case-view-status">
<EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiBadge color={badgeColor} data-test-subj="case-view-status">
{status}
</EuiBadge>
<StatusContextMenu
currentStatus={caseData.status}
onStatusChanged={onStatusChanged}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<FormattedRelativePreferenceDate data-test-subj={dataTestSubj} value={value} />
<FormattedRelativePreferenceDate
data-test-subj={'case-action-bar-status-date'}
value={date}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
@ -95,18 +84,6 @@ const CaseStatusComp: React.FC<CaseStatusProps> = ({
{i18n.CASE_REFRESH}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
data-test-subj="toggle-case-status"
isDisabled={disabled}
iconType={icon}
isLoading={isLoading}
fill={isSelected}
onClick={handleToggleStatusCase}
>
{buttonLabel}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CaseViewActions
caseData={caseData}
@ -120,4 +97,4 @@ const CaseStatusComp: React.FC<CaseStatusProps> = ({
);
};
export const CaseStatus = React.memo(CaseStatusComp);
export const CaseActionBar = React.memo(CaseActionBarComponent);

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import { memoize } from 'lodash/fp';
import { EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { Status, statuses } from '../status';
interface Props {
currentStatus: CaseStatuses;
onStatusChanged: (status: CaseStatuses) => void;
}
const StatusContextMenuComponent: React.FC<Props> = ({ currentStatus, onStatusChanged }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const openPopover = useCallback(() => setIsPopoverOpen(true), []);
const popOverButton = useMemo(
() => <Status type={currentStatus} withArrow onClick={openPopover} />,
[currentStatus, openPopover]
);
const onContextMenuItemClick = useMemo(
() =>
memoize<(status: CaseStatuses) => () => void>((status) => () => {
closePopover();
onStatusChanged(status);
}),
[closePopover, onStatusChanged]
);
const caseStatuses = Object.keys(statuses) as CaseStatuses[];
const panelItems = caseStatuses.map((status: CaseStatuses) => (
<EuiContextMenuItem
key={status}
icon={status === currentStatus ? 'check' : 'empty'}
onClick={onContextMenuItemClick(status)}
data-test-subj={`case-view-status-dropdown-${status}`}
>
<Status type={status} />
</EuiContextMenuItem>
));
return (
<>
<EuiPopover
id="caseStatusPopover"
button={popOverButton}
isOpen={isPopoverOpen}
closePopover={closePopover}
anchorPosition="downLeft"
data-test-subj="case-view-status-dropdown"
>
<EuiContextMenuPanel items={panelItems} />
</EuiPopover>
</>
);
};
export const StatusContextMenu = memo(StatusContextMenuComponent);

View file

@ -114,8 +114,8 @@ describe('CaseView ', () => {
data.title
);
expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual(
data.status
expect(wrapper.find(`[data-test-subj="case-view-status-dropdown"]`).first().text()).toEqual(
'Open'
);
expect(
@ -136,11 +136,9 @@ describe('CaseView ', () => {
data.createdBy.username
);
expect(wrapper.contains(`[data-test-subj="case-view-closedAt"]`)).toBe(false);
expect(wrapper.find(`[data-test-subj="case-view-createdAt"]`).first().prop('value')).toEqual(
data.createdAt
);
expect(
wrapper.find(`[data-test-subj="case-action-bar-status-date"]`).first().prop('value')
).toEqual(data.createdAt);
expect(
wrapper
@ -156,6 +154,7 @@ describe('CaseView ', () => {
...defaultUpdateCaseState,
caseData: basicCaseClosed,
}));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -163,18 +162,18 @@ describe('CaseView ', () => {
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false);
expect(wrapper.find(`[data-test-subj="case-view-closedAt"]`).first().prop('value')).toEqual(
basicCaseClosed.closedAt
);
expect(wrapper.find(`[data-test-subj="case-view-status"]`).first().text()).toEqual(
basicCaseClosed.status
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 dispatch update state when button is toggled', async () => {
it('should dispatch update state when status is changed', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -182,8 +181,14 @@ describe('CaseView ', () => {
</Router>
</TestProviders>
);
await waitFor(() => {
wrapper.find('[data-test-subj="toggle-case-status"]').first().simulate('click');
wrapper.find('[data-test-subj="case-view-status-dropdown"] button').first().simulate('click');
wrapper.update();
wrapper
.find('button[data-test-subj="case-view-status-dropdown-closed"]')
.first()
.simulate('click');
expect(updateCaseProperty).toHaveBeenCalled();
});
});
@ -211,26 +216,6 @@ describe('CaseView ', () => {
});
});
it('should display Toggle Status isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,
isLoading: true,
updateKey: 'status',
}));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="toggle-case-status"]').first().prop('isLoading')
).toBeTruthy();
});
});
it('should display description isLoading', async () => {
useUpdateCaseMock.mockImplementation(() => ({
...defaultUpdateCaseState,

View file

@ -5,7 +5,6 @@
*/
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
@ -16,7 +15,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import * as i18n from './translations';
import { CaseStatuses } from '../../../../../case/common/api';
import { Case, CaseConnector } from '../../containers/types';
import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to';
import { gutterTimeline } from '../../../common/lib/helpers';
@ -29,7 +28,7 @@ import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { getTypedPayload } from '../../containers/utils';
import { WhitePageWrapper, HeaderWrapper } from '../wrappers';
import { CaseStatus } from '../case_status';
import { CaseActionBar } from '../case_action_bar';
import { SpyRoute } from '../../../common/utils/route/spy_routes';
import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions';
import { usePushToService } from '../use_push_to_service';
@ -41,6 +40,9 @@ import {
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { StatusActionButton } from '../status/button';
import * as i18n from './translations';
interface Props {
caseId: string;
@ -159,7 +161,7 @@ export const CaseComponent = React.memo<CaseProps>(
});
break;
case 'status':
const statusUpdate = getTypedPayload<string>(value);
const statusUpdate = getTypedPayload<CaseStatuses>(value);
if (caseData.status !== value) {
updateCaseProperty({
fetchCaseUserActions,
@ -241,11 +243,11 @@ export const CaseComponent = React.memo<CaseProps>(
[onUpdateField]
);
const toggleStatusCase = useCallback(
(nextStatus) =>
const changeStatus = useCallback(
(status: CaseStatuses) =>
onUpdateField({
key: 'status',
value: nextStatus ? 'closed' : 'open',
value: status,
}),
[onUpdateField]
);
@ -257,32 +259,6 @@ export const CaseComponent = React.memo<CaseProps>(
const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]);
const caseStatusData = useMemo(
() =>
caseData.status === 'open'
? {
'data-test-subj': 'case-view-createdAt',
value: caseData.createdAt,
title: i18n.CASE_OPENED,
buttonLabel: i18n.CLOSE_CASE,
status: caseData.status,
icon: 'folderCheck',
badgeColor: 'secondary',
isSelected: false,
}
: {
'data-test-subj': 'case-view-closedAt',
value: caseData.closedAt ?? '',
title: i18n.CASE_CLOSED,
buttonLabel: i18n.REOPEN_CASE,
status: caseData.status,
icon: 'folderExclamation',
badgeColor: 'danger',
isSelected: true,
},
[caseData.closedAt, caseData.createdAt, caseData.status]
);
const emailContent = useMemo(
() => ({
subject: i18n.EMAIL_SUBJECT(caseData.title),
@ -307,11 +283,6 @@ export const CaseComponent = React.memo<CaseProps>(
[allCasesLink]
);
const isSelected = useMemo(() => caseStatusData.isSelected, [caseStatusData]);
const handleToggleStatusCase = useCallback(() => {
toggleStatusCase(!isSelected);
}, [toggleStatusCase, isSelected]);
return (
<>
<HeaderWrapper>
@ -329,14 +300,13 @@ export const CaseComponent = React.memo<CaseProps>(
}
title={caseData.title}
>
<CaseStatus
<CaseActionBar
currentExternalIncident={currentExternalIncident}
caseData={caseData}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
onRefresh={handleRefresh}
toggleStatusCase={handleToggleStatusCase}
{...caseStatusData}
onStatusChanged={changeStatus}
/>
</HeaderPage>
</HeaderWrapper>
@ -363,16 +333,12 @@ export const CaseComponent = React.memo<CaseProps>(
<MyEuiHorizontalRule margin="s" />
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj={caseStatusData['data-test-subj']}
iconType={caseStatusData.icon}
isDisabled={!userCanCrud}
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
fill={caseStatusData.isSelected}
onClick={handleToggleStatusCase}
>
{caseStatusData.buttonLabel}
</EuiButton>
/>
</EuiFlexItem>
{hasDataToPush && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>

View file

@ -128,14 +128,6 @@ export const COMMENT = i18n.translate('xpack.securitySolution.case.caseView.comm
defaultMessage: 'comment',
});
export const CASE_OPENED = i18n.translate('xpack.securitySolution.case.caseView.caseOpened', {
defaultMessage: 'Case opened',
});
export const CASE_CLOSED = i18n.translate('xpack.securitySolution.case.caseView.caseClosed', {
defaultMessage: 'Case closed',
});
export const CASE_REFRESH = i18n.translate('xpack.securitySolution.case.caseView.caseRefresh', {
defaultMessage: 'Refresh case',
});

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui';
import * as i18n from '../all_cases/translations';
export interface Props {
caseCount: number | null;
caseStatus: 'open' | 'closed';
isLoading: boolean;
dataTestSubj?: string;
}
export const OpenClosedStats = React.memo<Props>(
({ caseCount, caseStatus, isLoading, dataTestSubj }) => {
const openClosedStats = useMemo(
() => [
{
title: caseStatus === 'open' ? i18n.OPEN_CASES : i18n.CLOSED_CASES,
description: isLoading ? <EuiLoadingSpinner /> : caseCount ?? 'N/A',
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[caseCount, caseStatus, isLoading, dataTestSubj]
);
return (
<EuiDescriptionList
data-test-subj={dataTestSubj}
textStyle="reverse"
listItems={openClosedStats}
/>
);
}
);
OpenClosedStats.displayName = 'OpenClosedStats';

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useCallback, useMemo } from 'react';
import { EuiButton } from '@elastic/eui';
import { CaseStatuses, caseStatuses } from '../../../../../case/common/api';
import { statuses } from './config';
interface Props {
status: CaseStatuses;
disabled: boolean;
isLoading: boolean;
onStatusChanged: (status: CaseStatuses) => void;
}
// Rotate over the statuses. open -> in-progress -> closes -> open...
const getNextItem = (item: number) => (item + 1) % caseStatuses.length;
const StatusActionButtonComponent: React.FC<Props> = ({
status,
onStatusChanged,
disabled,
isLoading,
}) => {
const indexOfCurrentStatus = useMemo(
() => caseStatuses.findIndex((caseStatus) => caseStatus === status),
[status]
);
const nextStatusIndex = useMemo(() => getNextItem(indexOfCurrentStatus), [indexOfCurrentStatus]);
const onClick = useCallback(() => {
onStatusChanged(caseStatuses[nextStatusIndex]);
}, [nextStatusIndex, onStatusChanged]);
return (
<EuiButton
data-test-subj={'case-view-status-action-button'}
iconType={statuses[caseStatuses[nextStatusIndex]].button.icon}
isDisabled={disabled}
isLoading={isLoading}
onClick={onClick}
>
{statuses[caseStatuses[nextStatusIndex]].button.label}
</EuiButton>
);
};
export const StatusActionButton = memo(StatusActionButtonComponent);

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CaseStatuses } from '../../../../../case/common/api';
import * as i18n from './translations';
type Statuses = Record<
CaseStatuses,
{
color: string;
label: string;
actionBar: {
title: string;
};
button: {
label: string;
icon: string;
};
stats: {
title: string;
};
}
>;
export const statuses: Statuses = {
[CaseStatuses.open]: {
color: 'primary',
label: i18n.OPEN,
actionBar: {
title: i18n.CASE_OPENED,
},
button: {
label: i18n.REOPEN_CASE,
icon: 'folderCheck',
},
stats: {
title: i18n.OPEN_CASES,
},
},
[CaseStatuses['in-progress']]: {
color: 'warning',
label: i18n.IN_PROGRESS,
actionBar: {
title: i18n.CASE_IN_PROGRESS,
},
button: {
label: i18n.MARK_CASE_IN_PROGRESS,
icon: 'folderExclamation',
},
stats: {
title: i18n.IN_PROGRESS_CASES,
},
},
[CaseStatuses.closed]: {
color: 'default',
label: i18n.CLOSED,
actionBar: {
title: i18n.CASE_CLOSED,
},
button: {
label: i18n.CLOSE_CASE,
icon: 'folderCheck',
},
stats: {
title: i18n.CLOSED_CASES,
},
},
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './status';
export * from './config';
export * from './stats';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useMemo } from 'react';
import { EuiDescriptionList, EuiLoadingSpinner } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { statuses } from './config';
export interface Props {
caseCount: number | null;
caseStatus: CaseStatuses;
isLoading: boolean;
dataTestSubj?: string;
}
const StatsComponent: React.FC<Props> = ({ caseCount, caseStatus, isLoading, dataTestSubj }) => {
const statusStats = useMemo(
() => [
{
title: statuses[caseStatus].stats.title,
description: isLoading ? <EuiLoadingSpinner /> : caseCount ?? 'N/A',
},
],
[caseCount, caseStatus, isLoading]
);
return (
<EuiDescriptionList data-test-subj={dataTestSubj} textStyle="reverse" listItems={statusStats} />
);
};
StatsComponent.displayName = 'StatsComponent';
export const Stats = memo(StatsComponent);

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { memo, useMemo } from 'react';
import { noop } from 'lodash/fp';
import { EuiBadge } from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { statuses } from './config';
import * as i18n from './translations';
interface Props {
type: CaseStatuses;
withArrow?: boolean;
onClick?: () => void;
}
const StatusComponent: React.FC<Props> = ({ type, withArrow = false, onClick = noop }) => {
const props = useMemo(
() => ({
color: statuses[type].color,
...(withArrow ? { iconType: 'arrowDown', iconSide: 'right' as const } : {}),
}),
[withArrow, type]
);
return (
<EuiBadge
{...props}
iconOnClick={onClick}
iconOnClickAriaLabel={i18n.STATUS_ICON_ARIA}
data-test-subj={`status-badge-${type}`}
>
{statuses[type].label}
</EuiBadge>
);
};
export const Status = memo(StatusComponent);

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export * from '../../translations';
export const OPEN = i18n.translate('xpack.securitySolution.case.status.open', {
defaultMessage: 'Open',
});
export const IN_PROGRESS = i18n.translate('xpack.securitySolution.case.status.inProgress', {
defaultMessage: 'In progress',
});
export const CLOSED = i18n.translate('xpack.securitySolution.case.status.closed', {
defaultMessage: 'Closed',
});
export const STATUS_ICON_ARIA = i18n.translate('xpack.securitySolution.case.status.iconAria', {
defaultMessage: 'Change status',
});
export const CASE_OPENED = i18n.translate('xpack.securitySolution.case.caseView.caseOpened', {
defaultMessage: 'Case opened',
});
export const CASE_IN_PROGRESS = i18n.translate(
'xpack.securitySolution.case.caseView.caseInProgress',
{
defaultMessage: 'Case in progress',
}
);
export const CASE_CLOSED = i18n.translate('xpack.securitySolution.case.caseView.caseClosed', {
defaultMessage: 'Case closed',
});

View file

@ -11,6 +11,7 @@ import '../../../common/mock/match_media';
import { usePushToService, ReturnUsePushToService, UsePushToService } from '.';
import { TestProviders } from '../../../common/mock';
import { CaseStatuses } from '../../../../../case/common/api';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { basicPush, actionLicenses } from '../../containers/mock';
import { useGetActionLicense } from '../../containers/use_get_action_license';
@ -61,7 +62,7 @@ describe('usePushToService', () => {
},
caseId,
caseServices,
caseStatus: 'open',
caseStatus: CaseStatuses.open,
connectors: connectorsMock,
updateCase,
userCanCrud: true,
@ -252,7 +253,7 @@ describe('usePushToService', () => {
() =>
usePushToService({
...defaultArgs,
caseStatus: 'closed',
caseStatus: CaseStatuses.closed,
}),
{
wrapper: ({ children }) => <TestProviders> {children}</TestProviders>,

View file

@ -16,7 +16,7 @@ import { getConfigureCasesUrl, useFormatUrl } from '../../../common/components/l
import { CaseCallOut } from '../callout';
import { getLicenseError, getKibanaConfigError } from './helpers';
import * as i18n from './translations';
import { CaseConnector, ActionConnector } from '../../../../../case/common/api';
import { CaseConnector, ActionConnector, CaseStatuses } from '../../../../../case/common/api';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { LinkAnchor } from '../../../common/components/links';
import { SecurityPageName } from '../../../app/types';
@ -133,7 +133,7 @@ export const usePushToService = ({
},
];
}
if (caseStatus === 'closed') {
if (caseStatus === CaseStatuses.closed) {
errors = [
...errors,
{

View file

@ -5,11 +5,13 @@
*/
import React from 'react';
import { CaseStatuses } from '../../../../../case/common/api';
import { basicPush, getUserAction } from '../../containers/mock';
import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers';
import * as i18n from '../case_view/translations';
import { mount } from 'enzyme';
import { connectorsMock } from '../../containers/configure/mock';
import * as i18n from './translations';
describe('User action tree helpers', () => {
const connectors = connectorsMock;
@ -54,24 +56,24 @@ describe('User action tree helpers', () => {
expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`);
});
it('label title generated for update status to open', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: 'open' };
it.skip('label title generated for update status to open', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.open };
const result: string | JSX.Element = getLabelTitle({
action,
field: 'status',
});
expect(result).toEqual(`${i18n.REOPENED_CASE.toLowerCase()} ${i18n.CASE}`);
expect(result).toEqual(`${i18n.REOPEN_CASE.toLowerCase()} ${i18n.CASE}`);
});
it('label title generated for update status to closed', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: 'closed' };
it.skip('label title generated for update status to closed', () => {
const action = { ...getUserAction(['status'], 'update'), newValue: CaseStatuses.closed };
const result: string | JSX.Element = getLabelTitle({
action,
field: 'status',
});
expect(result).toEqual(`${i18n.CLOSED_CASE.toLowerCase()} ${i18n.CASE}`);
expect(result).toEqual(`${i18n.CLOSE_CASE.toLowerCase()} ${i18n.CASE}`);
});
it('label title generated for update comment', () => {

View file

@ -7,22 +7,38 @@
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps } from '@elastic/eui';
import React from 'react';
import { CaseFullExternalService, ActionConnector } from '../../../../../case/common/api';
import {
CaseFullExternalService,
ActionConnector,
CaseStatuses,
} from '../../../../../case/common/api';
import { CaseUserActions } from '../../containers/types';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { parseString } from '../../containers/utils';
import { Tags } from '../tag_list/tags';
import * as i18n from '../case_view/translations';
import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionMoveToReference } from './user_action_move_to_reference';
import { Status, statuses } from '../status';
import * as i18n from '../case_view/translations';
interface LabelTitle {
action: CaseUserActions;
field: string;
}
const getStatusTitle = (status: CaseStatuses) => {
return (
<EuiFlexGroup gutterSize="s" alignItems={'center'}>
<EuiFlexItem grow={false}>{i18n.MARKED_CASE_AS}</EuiFlexItem>
<EuiFlexItem grow={false}>
<Status type={status} />
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const getLabelTitle = ({ action, field }: LabelTitle) => {
if (field === 'tags') {
return getTagsLabelTitle(action);
@ -33,9 +49,12 @@ export const getLabelTitle = ({ action, field }: LabelTitle) => {
} else if (field === 'description' && action.action === 'update') {
return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`;
} else if (field === 'status' && action.action === 'update') {
return `${
action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase()
} ${i18n.CASE}`;
if (!Object.prototype.hasOwnProperty.call(statuses, action.newValue ?? '')) {
return '';
}
// The above check ensures that the newValue is of type CaseStatuses.
return getStatusTitle(action.newValue as CaseStatuses);
} else if (field === 'comment' && action.action === 'update') {
return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
}
@ -120,6 +139,16 @@ export const getPushInfo = (
parsedConnectorName: 'none',
};
const getUpdateActionIcon = (actionField: string): string => {
if (actionField === 'tags') {
return 'tag';
} else if (actionField === 'status') {
return 'folderClosed';
}
return 'dot';
};
export const getUpdateAction = ({
action,
label,
@ -139,7 +168,7 @@ export const getUpdateAction = ({
event: label,
'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
timelineIcon: action.action === 'add' || action.action === 'delete' ? 'tag' : 'dot',
timelineIcon: getUpdateActionIcon(action.actionField[0]),
actions: (
<EuiFlexGroup>
<EuiFlexItem>

View file

@ -380,10 +380,10 @@ export const UserActionTree = React.memo(
];
}
// title, description, comments, tags
// title, description, comments, tags, status
if (
action.actionField.length === 1 &&
['title', 'description', 'comment', 'tags'].includes(action.actionField[0])
['title', 'description', 'comment', 'tags', 'status'].includes(action.actionField[0])
) {
const myField = action.actionField[0];
const label: string | JSX.Element = getLabelTitle({

View file

@ -35,6 +35,7 @@ import {
ServiceConnectorCaseParams,
ServiceConnectorCaseResponse,
User,
CaseStatuses,
} from '../../../../../case/common/api';
export const getCase = async (
@ -62,7 +63,7 @@ export const getCases = async ({
filterOptions = {
search: '',
reporters: [],
status: 'open',
status: CaseStatuses.open,
tags: [],
},
queryParams = {

View file

@ -6,6 +6,7 @@
import { KibanaServices } from '../../common/lib/kibana';
import { ConnectorTypes, CommentType, CaseStatuses } from '../../../../case/common/api';
import { CASES_URL } from '../../../../case/common/constants';
import {
@ -51,7 +52,6 @@ import {
import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
import * as i18n from './translations';
import { ConnectorTypes, CommentType } from '../../../../case/common/api';
const abortCtrl = new AbortController();
const mockKibanaServices = KibanaServices.get as jest.Mock;
@ -138,7 +138,7 @@ describe('Case Configuration API', () => {
...DEFAULT_QUERY_PARAMS,
reporters: [],
tags: [],
status: 'open',
status: CaseStatuses.open,
},
signal: abortCtrl.signal,
});
@ -149,7 +149,7 @@ describe('Case Configuration API', () => {
...DEFAULT_FILTER_OPTIONS,
reporters: [...respReporters, { username: null, full_name: null, email: null }],
tags,
status: '',
status: CaseStatuses.open,
search: 'hello',
},
queryParams: DEFAULT_QUERY_PARAMS,
@ -162,6 +162,7 @@ describe('Case Configuration API', () => {
reporters,
tags: ['"coke"', '"pepsi"'],
search: 'hello',
status: CaseStatuses.open,
},
signal: abortCtrl.signal,
});
@ -174,7 +175,7 @@ describe('Case Configuration API', () => {
...DEFAULT_FILTER_OPTIONS,
reporters: [...respReporters, { username: null, full_name: null, email: null }],
tags: weirdTags,
status: '',
status: CaseStatuses.open,
search: 'hello',
},
queryParams: DEFAULT_QUERY_PARAMS,
@ -187,6 +188,7 @@ describe('Case Configuration API', () => {
reporters,
tags: ['"("', '"\\"double\\""'],
search: 'hello',
status: CaseStatuses.open,
},
signal: abortCtrl.signal,
});
@ -310,7 +312,7 @@ describe('Case Configuration API', () => {
});
const data = [
{
status: 'closed',
status: CaseStatuses.closed,
id: basicCase.id,
version: basicCase.version,
},

View file

@ -19,6 +19,7 @@ import {
ServiceConnectorCaseResponse,
ActionTypeExecutorResult,
CommentType,
CaseStatuses,
} from '../../../../case/common/api';
import {
@ -120,7 +121,7 @@ export const getCases = async ({
filterOptions = {
search: '',
reporters: [],
status: 'open',
status: CaseStatuses.open,
tags: [],
},
queryParams = {
@ -134,7 +135,7 @@ export const getCases = async ({
const query = {
reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''),
tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`),
...(filterOptions.status !== '' ? { status: filterOptions.status } : {}),
status: filterOptions.status,
...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}),
...queryParams,
};

View file

@ -9,7 +9,7 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment }
import {
CommentResponse,
ServiceConnectorCaseResponse,
Status,
CaseStatuses,
UserAction,
UserActionField,
CaseResponse,
@ -69,7 +69,7 @@ export const basicCase: Case = {
},
description: 'Security banana Issue',
externalService: null,
status: 'open',
status: CaseStatuses.open,
tags,
title: 'Another horrible breach!!',
totalComment: 1,
@ -98,8 +98,9 @@ export const basicCaseCommentPatch = {
};
export const casesStatus: CasesStatus = {
countClosedCases: 130,
countOpenCases: 20,
countInProgressCases: 40,
countClosedCases: 130,
};
export const basicPush = {
@ -203,7 +204,7 @@ export const basicCommentSnake: CommentResponse = {
export const basicCaseSnake: CaseResponse = {
...basicCase,
status: 'open' as Status,
status: CaseStatuses.open,
closed_at: null,
closed_by: null,
comments: [basicCommentSnake],
@ -222,6 +223,7 @@ export const basicCaseSnake: CaseResponse = {
export const casesStatusSnake: CasesStatusResponse = {
count_closed_cases: 130,
count_in_progress_cases: 40,
count_open_cases: 20,
};
@ -325,5 +327,5 @@ export const basicCaseClosed: Case = {
...basicCase,
closedAt: '2020-02-25T23:06:33.798Z',
closedBy: elasticUser,
status: 'closed',
status: CaseStatuses.closed,
};

View file

@ -10,6 +10,7 @@ import {
UserAction,
CaseConnector,
CommentType,
CaseStatuses,
} from '../../../../case/common/api';
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
@ -57,7 +58,7 @@ export interface Case {
createdBy: ElasticUser;
description: string;
externalService: CaseExternalService | null;
status: string;
status: CaseStatuses;
tags: string[];
title: string;
totalComment: number;
@ -75,7 +76,7 @@ export interface QueryParams {
export interface FilterOptions {
search: string;
status: string;
status: CaseStatuses;
tags: string[];
reporters: User[];
}
@ -83,6 +84,7 @@ export interface FilterOptions {
export interface CasesStatus {
countClosedCases: number | null;
countOpenCases: number | null;
countInProgressCases: number | null;
}
export interface AllCases extends CasesStatus {
@ -95,6 +97,7 @@ export interface AllCases extends CasesStatus {
export enum SortFieldCase {
createdAt = 'createdAt',
closedAt = 'closedAt',
updatedAt = 'updatedAt',
}
export interface ElasticUser {

View file

@ -5,6 +5,7 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { CaseStatuses } from '../../../../case/common/api';
import { useUpdateCases, UseUpdateCases } from './use_bulk_update_case';
import { basicCase } from './mock';
import * as api from './api';
@ -43,12 +44,12 @@ describe('useUpdateCases', () => {
);
await waitForNextUpdate();
result.current.updateBulkStatus([basicCase], 'closed');
result.current.updateBulkStatus([basicCase], CaseStatuses.closed);
await waitForNextUpdate();
expect(spyOnPatchCases).toBeCalledWith(
[
{
status: 'closed',
status: CaseStatuses.closed,
id: basicCase.id,
version: basicCase.version,
},
@ -64,7 +65,7 @@ describe('useUpdateCases', () => {
useUpdateCases()
);
await waitForNextUpdate();
result.current.updateBulkStatus([basicCase], 'closed');
result.current.updateBulkStatus([basicCase], CaseStatuses.closed);
await waitForNextUpdate();
expect(result.current).toEqual({
isUpdated: true,
@ -82,7 +83,7 @@ describe('useUpdateCases', () => {
useUpdateCases()
);
await waitForNextUpdate();
result.current.updateBulkStatus([basicCase], 'closed');
result.current.updateBulkStatus([basicCase], CaseStatuses.closed);
expect(result.current.isLoading).toBe(true);
});
@ -95,7 +96,7 @@ describe('useUpdateCases', () => {
);
await waitForNextUpdate();
result.current.updateBulkStatus([basicCase], 'closed');
result.current.updateBulkStatus([basicCase], CaseStatuses.closed);
await waitForNextUpdate();
expect(result.current.isUpdated).toBeTruthy();
result.current.dispatchResetIsUpdated();
@ -114,7 +115,7 @@ describe('useUpdateCases', () => {
useUpdateCases()
);
await waitForNextUpdate();
result.current.updateBulkStatus([basicCase], 'closed');
result.current.updateBulkStatus([basicCase], CaseStatuses.closed);
expect(result.current).toEqual({
isUpdated: false,

View file

@ -5,6 +5,7 @@
*/
import { useCallback, useReducer } from 'react';
import { CaseStatuses } from '../../../../case/common/api';
import {
displaySuccessToast,
errorToToaster,
@ -86,7 +87,7 @@ export const useUpdateCases = (): UseUpdateCases => {
caseTitle: resultCount === 1 ? firstTitle : '',
};
const message =
resultCount && patchResponse[0].status === 'open'
resultCount && patchResponse[0].status === CaseStatuses.open
? i18n.REOPENED_CASES(messageArgs)
: i18n.CLOSED_CASES(messageArgs);

View file

@ -5,6 +5,7 @@
*/
import { useEffect, useReducer, useCallback } from 'react';
import { CaseStatuses } from '../../../../case/common/api';
import { Case } from './types';
import * as i18n from './translations';
@ -66,7 +67,7 @@ export const initialData: Case = {
},
description: '',
externalService: null,
status: '',
status: CaseStatuses.open,
tags: [],
title: '',
totalComment: 0,

View file

@ -5,6 +5,7 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { CaseStatuses } from '../../../../case/common/api';
import {
DEFAULT_FILTER_OPTIONS,
DEFAULT_QUERY_PARAMS,
@ -157,7 +158,7 @@ describe('useGetCases', () => {
const newFilters = {
search: 'new',
tags: ['new'],
status: 'closed',
status: CaseStatuses.closed,
};
const { result, waitForNextUpdate } = renderHook<string, UseGetCases>(() => useGetCases());
await waitForNextUpdate();

View file

@ -5,6 +5,7 @@
*/
import { useCallback, useEffect, useReducer } from 'react';
import { CaseStatuses } from '../../../../case/common/api';
import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from './constants';
import { AllCases, SortFieldCase, FilterOptions, QueryParams, Case } from './types';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
@ -94,7 +95,7 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS
export const DEFAULT_FILTER_OPTIONS: FilterOptions = {
search: '',
reporters: [],
status: 'open',
status: CaseStatuses.open,
tags: [],
};
@ -108,6 +109,7 @@ export const DEFAULT_QUERY_PARAMS: QueryParams = {
export const initialData: AllCases = {
cases: [],
countClosedCases: null,
countInProgressCases: null,
countOpenCases: null,
page: 0,
perPage: 0,

View file

@ -27,6 +27,7 @@ describe('useGetCasesStatus', () => {
expect(result.current).toEqual({
countClosedCases: null,
countOpenCases: null,
countInProgressCases: null,
isLoading: true,
isError: false,
fetchCasesStatus: result.current.fetchCasesStatus,
@ -56,6 +57,7 @@ describe('useGetCasesStatus', () => {
expect(result.current).toEqual({
countClosedCases: casesStatus.countClosedCases,
countOpenCases: casesStatus.countOpenCases,
countInProgressCases: casesStatus.countInProgressCases,
isLoading: false,
isError: false,
fetchCasesStatus: result.current.fetchCasesStatus,
@ -79,6 +81,7 @@ describe('useGetCasesStatus', () => {
expect(result.current).toEqual({
countClosedCases: 0,
countOpenCases: 0,
countInProgressCases: 0,
isLoading: false,
isError: true,
fetchCasesStatus: result.current.fetchCasesStatus,

View file

@ -18,6 +18,7 @@ interface CasesStatusState extends CasesStatus {
const initialData: CasesStatusState = {
countClosedCases: null,
countInProgressCases: null,
countOpenCases: null,
isLoading: true,
isError: false,
@ -57,6 +58,7 @@ export const useGetCasesStatus = (): UseGetCasesStatus => {
});
setCasesStatusState({
countClosedCases: 0,
countInProgressCases: 0,
countOpenCases: 0,
isLoading: false,
isError: true,

View file

@ -65,8 +65,9 @@ export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U =>
export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({
cases: snakeCases.cases.map((snakeCase) => convertToCamelCase<CaseResponse, Case>(snakeCase)),
countClosedCases: snakeCases.count_closed_cases,
countOpenCases: snakeCases.count_open_cases,
countInProgressCases: snakeCases.count_in_progress_cases,
countClosedCases: snakeCases.count_closed_cases,
page: snakeCases.page,
perPage: snakeCases.per_page,
total: snakeCases.total,

View file

@ -115,10 +115,6 @@ export const CREATE_CASE = i18n.translate('xpack.securitySolution.case.caseView.
defaultMessage: 'Create case',
});
export const CLOSED_CASE = i18n.translate('xpack.securitySolution.case.caseView.closedCase', {
defaultMessage: 'Closed case',
});
export const CLOSE_CASE = i18n.translate('xpack.securitySolution.case.caseView.closeCase', {
defaultMessage: 'Close case',
});
@ -127,10 +123,6 @@ export const REOPEN_CASE = i18n.translate('xpack.securitySolution.case.caseView.
defaultMessage: 'Reopen case',
});
export const REOPENED_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenedCase', {
defaultMessage: 'Reopened case',
});
export const CASE_NAME = i18n.translate('xpack.securitySolution.case.caseView.caseName', {
defaultMessage: 'Case name',
});

View file

@ -115,22 +115,21 @@ export const CREATE_CASE = i18n.translate('xpack.securitySolution.case.caseView.
defaultMessage: 'Create case',
});
export const CLOSED_CASE = i18n.translate('xpack.securitySolution.case.caseView.closedCase', {
defaultMessage: 'Closed case',
});
export const CLOSE_CASE = i18n.translate('xpack.securitySolution.case.caseView.closeCase', {
defaultMessage: 'Close case',
});
export const MARK_CASE_IN_PROGRESS = i18n.translate(
'xpack.securitySolution.case.caseView.markInProgress',
{
defaultMessage: 'Mark in progress',
}
);
export const REOPEN_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenCase', {
defaultMessage: 'Reopen case',
});
export const REOPENED_CASE = i18n.translate('xpack.securitySolution.case.caseView.reopenedCase', {
defaultMessage: 'Reopened case',
});
export const CASE_NAME = i18n.translate('xpack.securitySolution.case.caseView.caseName', {
defaultMessage: 'Case name',
});
@ -238,3 +237,22 @@ export const NO_CONNECTOR = i18n.translate('xpack.securitySolution.case.common.n
export const UNKNOWN = i18n.translate('xpack.securitySolution.case.caseView.unknown', {
defaultMessage: 'Unknown',
});
export const MARKED_CASE_AS = i18n.translate('xpack.securitySolution.case.caseView.markedCaseAs', {
defaultMessage: 'marked case as',
});
export const OPEN_CASES = i18n.translate('xpack.securitySolution.case.caseTable.openCases', {
defaultMessage: 'Open cases',
});
export const CLOSED_CASES = i18n.translate('xpack.securitySolution.case.caseTable.closedCases', {
defaultMessage: 'Closed cases',
});
export const IN_PROGRESS_CASES = i18n.translate(
'xpack.securitySolution.case.caseTable.inProgressCases',
{
defaultMessage: 'In progress cases',
}
);

View file

@ -16387,7 +16387,6 @@
"xpack.securitySolution.case.caseView.caseOpened": "ケースを開きました",
"xpack.securitySolution.case.caseView.caseRefresh": "ケースを更新",
"xpack.securitySolution.case.caseView.closeCase": "ケースを閉じる",
"xpack.securitySolution.case.caseView.closedCase": "閉じたケース",
"xpack.securitySolution.case.caseView.closedOn": "終了日",
"xpack.securitySolution.case.caseView.cloudDeploymentLink": "クラウド展開",
"xpack.securitySolution.case.caseView.comment": "コメント",
@ -16435,7 +16434,6 @@
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoConfigTitle": "外部コネクターを構成",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoConnectors": "外部システムでケースを開いて更新するには、{link}を設定する必要があります。",
"xpack.securitySolution.case.caseView.reopenCase": "ケースを再開",
"xpack.securitySolution.case.caseView.reopenedCase": "ケースを再開する",
"xpack.securitySolution.case.caseView.reporterLabel": "報告者",
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です",
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します",

View file

@ -16405,7 +16405,6 @@
"xpack.securitySolution.case.caseView.caseOpened": "案例已打开",
"xpack.securitySolution.case.caseView.caseRefresh": "刷新案例",
"xpack.securitySolution.case.caseView.closeCase": "关闭案例",
"xpack.securitySolution.case.caseView.closedCase": "已关闭案例",
"xpack.securitySolution.case.caseView.closedOn": "关闭于",
"xpack.securitySolution.case.caseView.cloudDeploymentLink": "云部署",
"xpack.securitySolution.case.caseView.comment": "注释",
@ -16453,7 +16452,6 @@
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoConfigTitle": "配置外部连接器",
"xpack.securitySolution.case.caseView.pushToServiceDisableByNoConnectors": "要在外部系统上打开和更新案例,必须配置{link}。",
"xpack.securitySolution.case.caseView.reopenCase": "重新打开案例",
"xpack.securitySolution.case.caseView.reopenedCase": "重新打开的案例",
"xpack.securitySolution.case.caseView.reporterLabel": "报告者",
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件",
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件",

View file

@ -87,6 +87,67 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('filters by status', async () => {
const { body: openCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq);
const { body: toCloseCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq);
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
.send({
cases: [
{
id: toCloseCase.id,
version: toCloseCase.version,
status: 'closed',
},
],
})
.expect(200);
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc&status=open`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(body).to.eql({
...findCasesResp,
total: 1,
cases: [openCase],
count_open_cases: 1,
count_closed_cases: 1,
count_in_progress_cases: 0,
});
});
it('filters by reporters', async () => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq);
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc&reporters=elastic`)
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(body).to.eql({
...findCasesResp,
total: 1,
cases: [postedCase],
count_open_cases: 1,
});
});
it('correctly counts comments', async () => {
const { body: postedCase } = await supertest
.post(CASES_URL)
@ -127,8 +188,14 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('correctly counts open/closed', async () => {
it('correctly counts open/closed/in-progress', async () => {
await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq);
const { body: inProgreeCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq);
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
@ -149,6 +216,20 @@ export default ({ getService }: FtrProviderContext): void => {
})
.expect(200);
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
.send({
cases: [
{
id: inProgreeCase.id,
version: inProgreeCase.version,
status: 'in-progress',
},
],
})
.expect(200);
const { body } = await supertest
.get(`${CASES_URL}/_find?sortOrder=asc`)
.set('kbn-xsrf', 'true')
@ -157,7 +238,9 @@ export default ({ getService }: FtrProviderContext): void => {
expect(body.count_open_cases).to.eql(1);
expect(body.count_closed_cases).to.eql(1);
expect(body.count_in_progress_cases).to.eql(1);
});
it('unhappy path - 400s when bad query supplied', async () => {
await supertest
.get(`${CASES_URL}/_find?perPage=true`)

View file

@ -156,6 +156,28 @@ export default ({ getService }: FtrProviderContext): void => {
.expect(400);
});
it('unhappy path - 400s when unsupported status sent', async () => {
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq)
.expect(200);
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
.send({
cases: [
{
id: postedCase.id,
version: postedCase.version,
status: 'not-supported',
},
],
})
.expect(400);
});
it('unhappy path - 400s when bad connector type sent', async () => {
const { body: postedCase } = await supertest
.post(CASES_URL)

View file

@ -23,6 +23,12 @@ export default ({ getService }: FtrProviderContext): void => {
it('should return case statuses', async () => {
await supertest.post(CASES_URL).set('kbn-xsrf', 'true').send(postCaseReq);
const { body: inProgressCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
.send(postCaseReq);
const { body: postedCase } = await supertest
.post(CASES_URL)
.set('kbn-xsrf', 'true')
@ -43,6 +49,20 @@ export default ({ getService }: FtrProviderContext): void => {
})
.expect(200);
await supertest
.patch(CASES_URL)
.set('kbn-xsrf', 'true')
.send({
cases: [
{
id: inProgressCase.id,
version: inProgressCase.version,
status: 'in-progress',
},
],
})
.expect(200);
const { body } = await supertest
.get(CASE_STATUS_URL)
.set('kbn-xsrf', 'true')
@ -52,6 +72,7 @@ export default ({ getService }: FtrProviderContext): void => {
expect(body).to.eql({
count_open_cases: 1,
count_closed_cases: 1,
count_in_progress_cases: 1,
});
});
});

View file

@ -13,6 +13,7 @@ import {
CommentRequestUserType,
CommentRequestAlertType,
CommentType,
CaseStatuses,
} from '../../../../plugins/case/common/api';
export const defaultUser = { email: null, full_name: null, username: 'elastic' };
export const postCaseReq: CasePostRequest = {
@ -49,7 +50,7 @@ export const postCaseResp = (
closed_by: null,
created_by: defaultUser,
external_service: null,
status: 'open',
status: CaseStatuses.open,
updated_by: null,
});
@ -78,4 +79,5 @@ export const findCasesResp: CasesFindResponse = {
cases: [],
count_open_cases: 0,
count_closed_cases: 0,
count_in_progress_cases: 0,
};