[Security Solution][Case] Fix subcases bugs on detections and case view (#91836)

Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co>
This commit is contained in:
Christos Nasikas 2021-02-26 15:35:43 +02:00 committed by GitHub
parent a1d2d870d3
commit c2877a6d96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 686 additions and 369 deletions

View file

@ -1,35 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as rt from 'io-ts';
import { CaseAttributesRt } from './case';
import { CommentResponseRt } from './comment';
import { SubCaseAttributesRt, SubCaseResponseRt } from './sub_case';
export const CollectionSubCaseAttributesRt = rt.intersection([
rt.partial({ subCase: SubCaseAttributesRt }),
rt.type({
case: CaseAttributesRt,
}),
]);
export const CollectWithSubCaseResponseRt = rt.intersection([
CaseAttributesRt,
rt.type({
id: rt.string,
totalComment: rt.number,
version: rt.string,
}),
rt.partial({
subCase: SubCaseResponseRt,
totalAlerts: rt.number,
comments: rt.array(CommentResponseRt),
}),
]);
export type CollectionWithSubCaseResponse = rt.TypeOf<typeof CollectWithSubCaseResponseRt>;
export type CollectionWithSubCaseAttributes = rt.TypeOf<typeof CollectionSubCaseAttributesRt>;

View file

@ -11,4 +11,3 @@ export * from './comment';
export * from './status';
export * from './user_actions';
export * from './sub_case';
export * from './commentable_case';

View file

@ -24,8 +24,8 @@ export const getSubCasesUrl = (caseID: string): string => {
return SUB_CASES_URL.replace('{case_id}', caseID);
};
export const getSubCaseDetailsUrl = (caseID: string, subCaseID: string): string => {
return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID);
export const getSubCaseDetailsUrl = (caseID: string, subCaseId: string): string => {
return SUB_CASE_DETAILS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId);
};
export const getCaseCommentsUrl = (id: string): string => {
@ -40,8 +40,8 @@ export const getCaseUserActionUrl = (id: string): string => {
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => {
return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID);
export const getSubCaseUserActionUrl = (caseID: string, subCaseId: string): string => {
return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseId);
};
export const getCasePushUrl = (caseId: string, connectorId: string): string => {

View file

@ -9,14 +9,37 @@ import { either, fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import * as rt from 'io-ts';
import { failure } from 'io-ts/lib/PathReporter';
import { isObject } from 'lodash/fp';
type ErrorFactory = (message: string) => Error;
export const formatErrors = (errors: rt.Errors): string[] => {
const err = errors.map((error) => {
if (error.message != null) {
return error.message;
} else {
const keyContext = error.context
.filter(
(entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== ''
)
.map((entry) => entry.key)
.join(',');
const nameContext = error.context.find((entry) => entry.type?.name?.length > 0);
const suppliedValue =
keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : '';
const value = isObject(error.value) ? JSON.stringify(error.value) : error.value;
return `Invalid value "${value}" supplied to "${suppliedValue}"`;
}
});
return [...new Set(err)];
};
export const createPlainError = (message: string) => new Error(message);
export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => {
throw createError(failure(errors).join('\n'));
throw createError(formatErrors(errors).join());
};
export const decodeOrThrow = <A, O, I>(

View file

@ -72,7 +72,7 @@ export interface TransformFieldsArgs<P, S> {
export interface ExternalServiceComment {
comment: string;
commentId?: string;
commentId: string;
}
export interface MapIncident {

View file

@ -540,6 +540,7 @@ describe('utils', () => {
},
{
comment: 'Elastic Security Alerts attached to the case: 3',
commentId: 'mock-id-1-total-alerts',
},
]);
});
@ -569,6 +570,7 @@ describe('utils', () => {
},
{
comment: 'Elastic Security Alerts attached to the case: 4',
commentId: 'mock-id-1-total-alerts',
},
]);
});

View file

@ -185,6 +185,7 @@ export const createIncident = async ({
if (totalAlerts > 0) {
comments.push({
comment: `Elastic Security Alerts attached to the case: ${totalAlerts}`,
commentId: `${theCase.id}-total-alerts`,
});
}

View file

@ -25,7 +25,7 @@ import {
CaseType,
SubCaseAttributes,
CommentRequest,
CollectionWithSubCaseResponse,
CaseResponse,
User,
CommentRequestAlertType,
AlertCommentRequestRt,
@ -113,7 +113,7 @@ const addGeneratedAlerts = async ({
caseClient,
caseId,
comment,
}: AddCommentFromRuleArgs): Promise<CollectionWithSubCaseResponse> => {
}: AddCommentFromRuleArgs): Promise<CaseResponse> => {
const query = pipe(
AlertCommentRequestRt.decode(comment),
fold(throwErrors(Boom.badRequest), identity)
@ -260,7 +260,7 @@ export const addComment = async ({
caseId,
comment,
user,
}: AddCommentArgs): Promise<CollectionWithSubCaseResponse> => {
}: AddCommentArgs): Promise<CaseResponse> => {
const query = pipe(
CommentRequestRt.decode(comment),
fold(throwErrors(Boom.badRequest), identity)

View file

@ -13,7 +13,6 @@ import {
CasesPatchRequest,
CasesResponse,
CaseStatuses,
CollectionWithSubCaseResponse,
CommentRequest,
ConnectorMappingsAttributes,
GetFieldsResponse,
@ -89,7 +88,7 @@ export interface ConfigureFields {
* This represents the interface that other plugins can access.
*/
export interface CaseClient {
addComment(args: CaseClientAddComment): Promise<CollectionWithSubCaseResponse>;
addComment(args: CaseClientAddComment): Promise<CaseResponse>;
create(theCase: CasePostRequest): Promise<CaseResponse>;
get(args: CaseClientGet): Promise<CaseResponse>;
getAlerts(args: CaseClientGetAlerts): Promise<CaseClientGetAlertsResponse>;

View file

@ -17,8 +17,8 @@ import {
CaseSettings,
CaseStatuses,
CaseType,
CollectionWithSubCaseResponse,
CollectWithSubCaseResponseRt,
CaseResponse,
CaseResponseRt,
CommentAttributes,
CommentPatchRequest,
CommentRequest,
@ -254,7 +254,7 @@ export class CommentableCase {
};
}
public async encode(): Promise<CollectionWithSubCaseResponse> {
public async encode(): Promise<CaseResponse> {
const collectionCommentStats = await this.service.getAllCaseComments({
client: this.soClient,
id: this.collection.id,
@ -265,22 +265,6 @@ export class CommentableCase {
},
});
if (this.subCase) {
const subCaseComments = await this.service.getAllSubCaseComments({
client: this.soClient,
id: this.subCase.id,
});
return CollectWithSubCaseResponseRt.encode({
subCase: flattenSubCaseSavedObject({
savedObject: this.subCase,
comments: subCaseComments.saved_objects,
totalAlerts: countAlertsForID({ comments: subCaseComments, id: this.subCase.id }),
}),
...this.formatCollectionForEncoding(collectionCommentStats.total),
});
}
const collectionComments = await this.service.getAllCaseComments({
client: this.soClient,
id: this.collection.id,
@ -291,10 +275,45 @@ export class CommentableCase {
},
});
return CollectWithSubCaseResponseRt.encode({
const collectionTotalAlerts =
countAlertsForID({ comments: collectionComments, id: this.collection.id }) ?? 0;
const caseResponse = {
comments: flattenCommentSavedObjects(collectionComments.saved_objects),
totalAlerts: countAlertsForID({ comments: collectionComments, id: this.collection.id }),
totalAlerts: collectionTotalAlerts,
...this.formatCollectionForEncoding(collectionCommentStats.total),
});
};
if (this.subCase) {
const subCaseComments = await this.service.getAllSubCaseComments({
client: this.soClient,
id: this.subCase.id,
});
const totalAlerts = countAlertsForID({ comments: subCaseComments, id: this.subCase.id }) ?? 0;
return CaseResponseRt.encode({
...caseResponse,
/**
* For now we need the sub case comments and totals to be exposed on the top level of the response so that the UI
* functionality can stay the same. Ideally in the future we can refactor this so that the UI will look for the
* comments either in the top level for a case or a collection or in the subCases field if it is a sub case.
*
* If we ever need to return both the collection's comments and the sub case comments we'll need to refactor it then
* as well.
*/
comments: flattenCommentSavedObjects(subCaseComments.saved_objects),
totalComment: subCaseComments.saved_objects.length,
totalAlerts,
subCases: [
flattenSubCaseSavedObject({
savedObject: this.subCase,
totalComment: subCaseComments.saved_objects.length,
totalAlerts,
}),
],
});
}
return CaseResponseRt.encode(caseResponse);
}
}

View file

@ -18,7 +18,6 @@ import {
AssociationType,
CaseResponse,
CasesResponse,
CollectionWithSubCaseResponse,
} from '../../../common/api';
import {
connectorMappingsServiceMock,
@ -1018,9 +1017,10 @@ describe('case connector', () => {
describe('addComment', () => {
it('executes correctly', async () => {
const commentReturn: CollectionWithSubCaseResponse = {
const commentReturn: CaseResponse = {
id: 'mock-it',
totalComment: 0,
totalAlerts: 0,
version: 'WzksMV0=',
closed_at: null,

View file

@ -16,7 +16,7 @@ import {
ConnectorSchema,
CommentSchema,
} from './schema';
import { CaseResponse, CasesResponse, CollectionWithSubCaseResponse } from '../../../common/api';
import { CaseResponse, CasesResponse } from '../../../common/api';
export type CaseConfiguration = TypeOf<typeof CaseConfigurationSchema>;
export type Connector = TypeOf<typeof ConnectorSchema>;
@ -29,7 +29,7 @@ export type ExecutorSubActionAddCommentParams = TypeOf<
>;
export type CaseExecutorParams = TypeOf<typeof CaseExecutorParamsSchema>;
export type CaseExecutorResponse = CaseResponse | CasesResponse | CollectionWithSubCaseResponse;
export type CaseExecutorResponse = CaseResponse | CasesResponse;
export type CaseActionType = ActionType<
CaseConfiguration,

View file

@ -23,7 +23,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic
}),
query: schema.maybe(
schema.object({
subCaseID: schema.maybe(schema.string()),
subCaseId: schema.maybe(schema.string()),
})
),
},
@ -35,11 +35,11 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic
const { username, full_name, email } = await caseService.getUser({ request });
const deleteDate = new Date().toISOString();
const id = request.query?.subCaseID ?? request.params.case_id;
const id = request.query?.subCaseId ?? request.params.case_id;
const comments = await caseService.getCommentsByAssociation({
client,
id,
associationType: request.query?.subCaseID
associationType: request.query?.subCaseId
? AssociationType.subCase
: AssociationType.case,
});
@ -61,7 +61,7 @@ export function initDeleteAllCommentsApi({ caseService, router, userActionServic
actionAt: deleteDate,
actionBy: { username, full_name, email },
caseId: request.params.case_id,
subCaseId: request.query?.subCaseID,
subCaseId: request.query?.subCaseId,
commentId: comment.id,
fields: ['comment'],
})

View file

@ -25,7 +25,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }:
}),
query: schema.maybe(
schema.object({
subCaseID: schema.maybe(schema.string()),
subCaseId: schema.maybe(schema.string()),
})
),
},
@ -46,8 +46,8 @@ export function initDeleteCommentApi({ caseService, router, userActionService }:
throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`);
}
const type = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
const id = request.query?.subCaseID ?? request.params.case_id;
const type = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
const id = request.query?.subCaseId ?? request.params.case_id;
const caseRef = myComment.references.find((c) => c.type === type);
if (caseRef == null || (caseRef != null && caseRef.id !== id)) {
@ -69,7 +69,7 @@ export function initDeleteCommentApi({ caseService, router, userActionService }:
actionAt: deleteDate,
actionBy: { username, full_name, email },
caseId: id,
subCaseId: request.query?.subCaseID,
subCaseId: request.query?.subCaseId,
commentId: request.params.comment_id,
fields: ['comment'],
}),

View file

@ -27,7 +27,7 @@ import { defaultPage, defaultPerPage } from '../..';
const FindQueryParamsRt = rt.partial({
...SavedObjectFindOptionsRt.props,
subCaseID: rt.string,
subCaseId: rt.string,
});
export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) {
@ -49,8 +49,8 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) {
fold(throwErrors(Boom.badRequest), identity)
);
const id = query.subCaseID ?? request.params.case_id;
const associationType = query.subCaseID ? AssociationType.subCase : AssociationType.case;
const id = query.subCaseId ?? request.params.case_id;
const associationType = query.subCaseId ? AssociationType.subCase : AssociationType.case;
const args = query
? {
caseService,

View file

@ -25,7 +25,7 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) {
query: schema.maybe(
schema.object({
includeSubCaseComments: schema.maybe(schema.boolean()),
subCaseID: schema.maybe(schema.string()),
subCaseId: schema.maybe(schema.string()),
})
),
},
@ -35,10 +35,10 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) {
const client = context.core.savedObjects.client;
let comments: SavedObjectsFindResponse<CommentAttributes>;
if (request.query?.subCaseID) {
if (request.query?.subCaseId) {
comments = await caseService.getAllSubCaseComments({
client,
id: request.query.subCaseID,
id: request.query.subCaseId,
options: {
sortField: defaultSortField,
},

View file

@ -26,11 +26,11 @@ interface CombinedCaseParams {
service: CaseServiceSetup;
client: SavedObjectsClientContract;
caseID: string;
subCaseID?: string;
subCaseId?: string;
}
async function getCommentableCase({ service, client, caseID, subCaseID }: CombinedCaseParams) {
if (subCaseID) {
async function getCommentableCase({ service, client, caseID, subCaseId }: CombinedCaseParams) {
if (subCaseId) {
const [caseInfo, subCase] = await Promise.all([
service.getCase({
client,
@ -38,7 +38,7 @@ async function getCommentableCase({ service, client, caseID, subCaseID }: Combin
}),
service.getSubCase({
client,
id: subCaseID,
id: subCaseId,
}),
]);
return new CommentableCase({ collection: caseInfo, service, subCase, soClient: client });
@ -66,7 +66,7 @@ export function initPatchCommentApi({
}),
query: schema.maybe(
schema.object({
subCaseID: schema.maybe(schema.string()),
subCaseId: schema.maybe(schema.string()),
})
),
body: escapeHatch,
@ -87,7 +87,7 @@ export function initPatchCommentApi({
service: caseService,
client,
caseID: request.params.case_id,
subCaseID: request.query?.subCaseID,
subCaseId: request.query?.subCaseId,
});
const myComment = await caseService.getComment({
@ -103,7 +103,7 @@ export function initPatchCommentApi({
throw Boom.badRequest(`You cannot change the type of the comment.`);
}
const saveObjType = request.query?.subCaseID ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
const saveObjType = request.query?.subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
const caseRef = myComment.references.find((c) => c.type === saveObjType);
if (caseRef == null || (caseRef != null && caseRef.id !== commentableCase.id)) {
@ -144,7 +144,7 @@ export function initPatchCommentApi({
actionAt: updatedDate,
actionBy: { username, full_name, email },
caseId: request.params.case_id,
subCaseId: request.query?.subCaseID,
subCaseId: request.query?.subCaseId,
commentId: updatedComment.id,
fields: ['comment'],
newValue: JSON.stringify(queryRestAttributes),

View file

@ -21,7 +21,7 @@ export function initPostCommentApi({ router }: RouteDeps) {
}),
query: schema.maybe(
schema.object({
subCaseID: schema.maybe(schema.string()),
subCaseId: schema.maybe(schema.string()),
})
),
body: escapeHatch,
@ -33,7 +33,7 @@ export function initPostCommentApi({ router }: RouteDeps) {
}
const caseClient = context.case.getCaseClient();
const caseId = request.query?.subCaseID ?? request.params.case_id;
const caseId = request.query?.subCaseId ?? request.params.case_id;
const comment = request.body as CommentRequest;
try {

View file

@ -153,8 +153,8 @@ async function getParentCases({
return parentCases.saved_objects.reduce((acc, so) => {
const subCaseIDsWithParent = parentIDInfo.parentIDToSubID.get(so.id);
subCaseIDsWithParent?.forEach((subCaseID) => {
acc.set(subCaseID, so);
subCaseIDsWithParent?.forEach((subCaseId) => {
acc.set(subCaseId, so);
});
return acc;
}, new Map<string, SavedObject<ESCaseAttributes>>());

View file

@ -8,12 +8,7 @@
import yargs from 'yargs';
import { ToolingLog } from '@kbn/dev-utils';
import { KbnClient } from '@kbn/test';
import {
CaseResponse,
CaseType,
CollectionWithSubCaseResponse,
ConnectorTypes,
} from '../../../common/api';
import { CaseResponse, CaseType, ConnectorTypes } from '../../../common/api';
import { CommentType } from '../../../common/api/cases/comment';
import { CASES_URL } from '../../../common/constants';
import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common';
@ -119,9 +114,7 @@ async function handleGenGroupAlerts(argv: any) {
),
};
const executeResp = await client.request<
ActionTypeExecutorResult<CollectionWithSubCaseResponse>
>({
const executeResp = await client.request<ActionTypeExecutorResult<CaseResponse>>({
path: `/api/actions/action/${createdAction.data.id}/_execute`,
method: 'POST',
body: {

View file

@ -8,7 +8,7 @@
import React from 'react';
import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui';
import styled from 'styled-components';
import { Case } from '../../containers/types';
import { Case, SubCase } from '../../containers/types';
import { CasesColumns } from './columns';
import { AssociationType } from '../../../../../case/common/api';
@ -34,14 +34,25 @@ BasicTable.displayName = 'BasicTable';
export const getExpandedRowMap = ({
data,
columns,
isModal,
onSubCaseClick,
}: {
data: Case[] | null;
columns: CasesColumns[];
isModal: boolean;
onSubCaseClick?: (theSubCase: SubCase) => void;
}): ExpandedRowMap => {
if (data == null) {
return {};
}
const rowProps = (theSubCase: SubCase) => {
return {
...(isModal && onSubCaseClick ? { onClick: () => onSubCaseClick(theSubCase) } : {}),
className: 'subCase',
};
};
return data.reduce((acc, curr) => {
if (curr.subCases != null) {
const subCases = curr.subCases.map((subCase, index) => ({
@ -58,6 +69,7 @@ export const getExpandedRowMap = ({
data-test-subj={`sub-cases-table-${curr.id}`}
itemId="id"
items={subCases}
rowProps={rowProps}
/>
),
};

View file

@ -19,11 +19,12 @@ import {
import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
import { isEmpty, memoize } from 'lodash/fp';
import styled, { css } from 'styled-components';
import * as i18n from './translations';
import classnames from 'classnames';
import { CaseStatuses } from '../../../../../case/common/api';
import * as i18n from './translations';
import { CaseStatuses, CaseType } from '../../../../../case/common/api';
import { getCasesColumns } from './columns';
import { Case, DeleteCase, FilterOptions, SortFieldCase } from '../../containers/types';
import { Case, DeleteCase, FilterOptions, SortFieldCase, SubCase } from '../../containers/types';
import { useGetCases, UpdateCase } from '../../containers/use_get_cases';
import { useGetCasesStatus } from '../../containers/use_get_cases_status';
import { useDeleteCases } from '../../containers/use_delete_cases';
@ -58,6 +59,7 @@ import { getExpandedRowMap } from './expanded_row';
const Div = styled.div`
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
`;
const FlexItemDivider = styled(EuiFlexItem)`
${({ theme }) => css`
.euiFlexGroup--gutterMedium > &.euiFlexItem {
@ -75,6 +77,7 @@ const ProgressLoader = styled(EuiProgress)`
z-index: ${theme.eui.euiZHeader};
`}
`;
const getSortField = (field: string): SortFieldCase => {
if (field === SortFieldCase.createdAt) {
return SortFieldCase.createdAt;
@ -86,19 +89,39 @@ const getSortField = (field: string): SortFieldCase => {
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
const BasicTable = styled(EuiBasicTable)`
.euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent {
padding: 8px 0 8px 32px;
}
${({ theme }) => `
.euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent {
padding: 8px 0 8px 32px;
}
&.isModal .euiTableRow.isDisabled {
cursor: not-allowed;
background-color: ${theme.eui.euiTableHoverClickableColor};
}
&.isModal .euiTableRow.euiTableRow-isExpandedRow .euiTableRowCell,
&.isModal .euiTableRow.euiTableRow-isExpandedRow:hover {
background-color: transparent;
}
&.isModal .euiTableRow.euiTableRow-isExpandedRow {
.subCase:hover {
background-color: ${theme.eui.euiTableHoverClickableColor};
}
}
`}
`;
BasicTable.displayName = 'BasicTable';
interface AllCasesProps {
onRowClick?: (theCase?: Case) => void;
onRowClick?: (theCase?: Case | SubCase) => void;
isModal?: boolean;
userCanCrud: boolean;
disabledStatuses?: CaseStatuses[];
disabledCases?: CaseType[];
}
export const AllCases = React.memo<AllCasesProps>(
({ onRowClick, isModal = false, userCanCrud }) => {
({ onRowClick, isModal = false, userCanCrud, disabledStatuses, disabledCases = [] }) => {
const { navigateToApp } = useKibana().services.application;
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case);
const { actionLicense } = useGetActionLicense();
@ -334,8 +357,10 @@ export const AllCases = React.memo<AllCasesProps>(
getExpandedRowMap({
columns: memoizedGetCasesColumns,
data: data.cases,
isModal,
onSubCaseClick: onRowClick,
}),
[data.cases, memoizedGetCasesColumns]
[data.cases, isModal, memoizedGetCasesColumns, onRowClick]
);
const memoizedPagination = useMemo(
@ -356,6 +381,7 @@ export const AllCases = React.memo<AllCasesProps>(
() => ({
selectable: (theCase) => isEmpty(theCase.subCases),
onSelectionChange: setSelectedCases,
selectableMessage: (selectable) => (!selectable ? i18n.SELECTABLE_MESSAGE_COLLECTIONS : ''),
}),
[setSelectedCases]
);
@ -377,7 +403,8 @@ export const AllCases = React.memo<AllCasesProps>(
return {
'data-test-subj': `cases-table-row-${theCase.id}`,
...(isModal ? { onClick: onTableRowClick } : {}),
className: classnames({ isDisabled: theCase.type === CaseType.collection }),
...(isModal && theCase.type !== CaseType.collection ? { onClick: onTableRowClick } : {}),
};
},
[isModal, onRowClick]
@ -462,6 +489,7 @@ export const AllCases = React.memo<AllCasesProps>(
status: filterOptions.status,
}}
setFilterRefetch={setFilterRefetch}
disabledStatuses={disabledStatuses}
/>
{isCasesLoading && isDataEmpty ? (
<Div>
@ -530,6 +558,7 @@ export const AllCases = React.memo<AllCasesProps>(
rowProps={tableRowProps}
selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined}
sorting={sorting}
className={classnames({ isModal })}
/>
</Div>
)}

View file

@ -61,4 +61,24 @@ describe('StatusFilter', () => {
expect(onStatusChanged).toBeCalledWith('closed');
});
});
it('should disabled selected statuses', () => {
const wrapper = mount(
<StatusFilter {...defaultProps} disabledStatuses={[CaseStatuses.closed]} />
);
wrapper.find('button[data-test-subj="case-status-filter"]').simulate('click');
expect(
wrapper.find('button[data-test-subj="case-status-filter-open"]').prop('disabled')
).toBeFalsy();
expect(
wrapper.find('button[data-test-subj="case-status-filter-in-progress"]').prop('disabled')
).toBeFalsy();
expect(
wrapper.find('button[data-test-subj="case-status-filter-closed"]').prop('disabled')
).toBeTruthy();
});
});

View file

@ -14,9 +14,15 @@ interface Props {
stats: Record<CaseStatuses, number>;
selectedStatus: CaseStatuses;
onStatusChanged: (status: CaseStatuses) => void;
disabledStatuses?: CaseStatuses[];
}
const StatusFilterComponent: React.FC<Props> = ({ stats, selectedStatus, onStatusChanged }) => {
const StatusFilterComponent: React.FC<Props> = ({
stats,
selectedStatus,
onStatusChanged,
disabledStatuses = [],
}) => {
const caseStatuses = Object.keys(statuses) as CaseStatuses[];
const options: Array<EuiSuperSelectOption<CaseStatuses>> = caseStatuses.map((status) => ({
value: status,
@ -28,6 +34,7 @@ const StatusFilterComponent: React.FC<Props> = ({ stats, selectedStatus, onStatu
<EuiFlexItem grow={false}>{` (${stats[status]})`}</EuiFlexItem>
</EuiFlexGroup>
),
disabled: disabledStatuses.includes(status),
'data-test-subj': `case-status-filter-${status}`,
}));

View file

@ -25,6 +25,7 @@ interface CasesTableFiltersProps {
onFilterChanged: (filterOptions: Partial<FilterOptions>) => void;
initial: FilterOptions;
setFilterRefetch: (val: () => void) => void;
disabledStatuses?: CaseStatuses[];
}
// Fix the width of the status dropdown to prevent hiding long text items
@ -50,6 +51,7 @@ const CasesTableFiltersComponent = ({
onFilterChanged,
initial = defaultInitial,
setFilterRefetch,
disabledStatuses,
}: CasesTableFiltersProps) => {
const [selectedReporters, setSelectedReporters] = useState(
initial.reporters.map((r) => r.full_name ?? r.username ?? '')
@ -158,6 +160,7 @@ const CasesTableFiltersComponent = ({
selectedStatus={initial.status}
onStatusChanged={onStatusChanged}
stats={stats}
disabledStatuses={disabledStatuses}
/>
</StatusFilterWrapper>
</EuiFlexGroup>

View file

@ -16,7 +16,7 @@ import {
EuiFlexItem,
EuiIconTip,
} from '@elastic/eui';
import { CaseStatuses } from '../../../../../case/common/api';
import { CaseStatuses, CaseType } from '../../../../../case/common/api';
import * as i18n from '../case_view/translations';
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
import { Actions } from './actions';
@ -73,19 +73,21 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
);
return (
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd">
<EuiFlexGroup gutterSize="l" justifyContent="flexEnd" data-test-subj="case-action-bar-wrapper">
<EuiFlexItem grow={false}>
<MyDescriptionList compressed>
<EuiFlexGroup>
<EuiFlexItem grow={false} data-test-subj="case-view-status">
<EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<StatusContextMenu
currentStatus={caseData.status}
onStatusChanged={onStatusChanged}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
{caseData.type !== CaseType.collection && (
<EuiFlexItem grow={false} data-test-subj="case-view-status">
<EuiDescriptionListTitle>{i18n.STATUS}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<StatusContextMenu
currentStatus={caseData.status}
onStatusChanged={onStatusChanged}
/>
</EuiDescriptionListDescription>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiDescriptionListTitle>{title}</EuiDescriptionListTitle>
<EuiDescriptionListDescription>

View file

@ -29,6 +29,7 @@ import { connectorsMock } from '../../containers/configure/mock';
import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
import { CaseType } from '../../../../../case/common/api';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
@ -201,6 +202,10 @@ describe('CaseView ', () => {
.first()
.text()
).toBe(data.description);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').first().text()
).toBe('Mark in progress');
});
});
@ -464,7 +469,7 @@ describe('CaseView ', () => {
);
await waitFor(() => {
wrapper.find('[data-test-subj="case-refresh"]').first().simulate('click');
expect(fetchCaseUserActions).toBeCalledWith(caseProps.caseData.id);
expect(fetchCaseUserActions).toBeCalledWith('1234', undefined);
expect(fetchCase).toBeCalled();
});
});
@ -547,8 +552,7 @@ describe('CaseView ', () => {
});
});
// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should update connector', async () => {
it('should update connector', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -752,4 +756,74 @@ describe('CaseView ', () => {
});
});
});
describe('Collections', () => {
it('it does not allow the user to update the status', async () => {
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="case-action-bar-wrapper"]').exists()).toBe(true);
expect(wrapper.find('button[data-test-subj="case-view-status"]').exists()).toBe(false);
expect(wrapper.find('[data-test-subj="user-actions"]').exists()).toBe(true);
expect(
wrapper.find('button[data-test-subj="case-view-status-action-button"]').exists()
).toBe(false);
});
});
it('it shows the push button when has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: true,
}));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</Router>
</TestProviders>
);
await waitFor(() => {
expect(wrapper.find('[data-test-subj="has-data-to-push-button"]').exists()).toBe(true);
});
});
it('it does not show the horizontal rule when does NOT has data to push', async () => {
useGetCaseUserActionsMock.mockImplementation(() => ({
...defaultUseGetCaseUserActions,
hasDataToPush: false,
}));
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent
{...caseProps}
caseData={{ ...caseProps.caseData, type: CaseType.collection }}
/>
</Router>
</TestProviders>
);
await waitFor(() => {
expect(
wrapper.find('[data-test-subj="case-view-bottom-actions-horizontal-rule"]').exists()
).toBe(false);
});
});
});
});

View file

@ -17,7 +17,7 @@ import {
EuiHorizontalRule,
} from '@elastic/eui';
import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api';
import { CaseStatuses, CaseAttributes, CaseType } 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';
@ -213,9 +213,9 @@ export const CaseComponent = React.memo<CaseProps>(
const handleUpdateCase = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchCaseUserActions(newCase.id);
fetchCaseUserActions(caseId, subCaseId);
},
[updateCase, fetchCaseUserActions]
[updateCase, fetchCaseUserActions, caseId, subCaseId]
);
const { loading: isLoadingConnectors, connectors } = useConnectors();
@ -283,9 +283,9 @@ export const CaseComponent = React.memo<CaseProps>(
);
const handleRefresh = useCallback(() => {
fetchCaseUserActions(caseData.id);
fetchCaseUserActions(caseId, subCaseId);
fetchCase();
}, [caseData.id, fetchCase, fetchCaseUserActions]);
}, [caseId, fetchCase, fetchCaseUserActions, subCaseId]);
const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]);
@ -345,6 +345,7 @@ export const CaseComponent = React.memo<CaseProps>(
);
}
}, [dispatch]);
return (
<>
<HeaderWrapper>
@ -387,7 +388,7 @@ export const CaseComponent = React.memo<CaseProps>(
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)}
fetchUserActions={fetchCaseUserActions.bind(null, caseId, subCaseId)}
isLoadingDescription={isLoading && updateKey === 'description'}
isLoadingUserActions={isLoadingUserActions}
onShowAlertDetails={showAlert}
@ -395,22 +396,29 @@ export const CaseComponent = React.memo<CaseProps>(
updateCase={updateCase}
userCanCrud={userCanCrud}
/>
<MyEuiHorizontalRule margin="s" />
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
{(caseData.type !== CaseType.collection || hasDataToPush) && (
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
<MyEuiHorizontalRule
margin="s"
data-test-subj="case-view-bottom-actions-horizontal-rule"
/>
</EuiFlexItem>
{hasDataToPush && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
{pushButton}
</EuiFlexItem>
)}
</EuiFlexGroup>
{caseData.type !== CaseType.collection && (
<EuiFlexItem grow={false}>
<StatusActionButton
status={caseData.status}
onStatusChanged={changeStatus}
disabled={!userCanCrud}
isLoading={isLoading && updateKey === 'status'}
/>
</EuiFlexItem>
)}
{hasDataToPush && (
<EuiFlexItem data-test-subj="has-data-to-push-button" grow={false}>
{pushButton}
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</>
)}
</EuiFlexItem>
@ -465,6 +473,7 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
if (isError) {
return null;
}
if (isLoading) {
return (
<MyEuiFlexGroup gutterSize="none" justifyContent="center" alignItems="center">
@ -476,14 +485,16 @@ export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) =
}
return (
<CaseComponent
caseId={caseId}
subCaseId={subCaseId}
fetchCase={fetchCase}
caseData={data}
updateCase={updateCase}
userCanCrud={userCanCrud}
/>
data && (
<CaseComponent
caseId={caseId}
subCaseId={subCaseId}
fetchCase={fetchCase}
caseData={data}
updateCase={updateCase}
userCanCrud={userCanCrud}
/>
)
);
});

View file

@ -47,7 +47,9 @@ const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionPara
const newProps = { ...actionParams.subActionParams, [key]: value };
editAction('subActionParams', newProps, index);
},
[actionParams.subActionParams, editAction, index]
// edit action causes re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
[actionParams.subActionParams, index]
);
const onCaseChanged = useCallback(

View file

@ -57,14 +57,18 @@ const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, sel
onCaseChanged('');
dispatchResetIsDeleted();
}
}, [isDeleted, dispatchResetIsDeleted, onCaseChanged]);
// onCaseChanged and/or dispatchResetIsDeleted causes re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDeleted]);
useEffect(() => {
if (!isLoading && !isError && data != null) {
setCreatedCase(data);
onCaseChanged(data.id);
}
}, [data, isLoading, isError, onCaseChanged]);
// onCaseChanged causes re-renders
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, isLoading, isError]);
return (
<>

View file

@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => {
onSuccess,
}: {
children: ReactNode;
onSuccess: ({ id }: { id: string }) => void;
onSuccess: ({ id }: { id: string }) => Promise<void>;
}) => {
return (
<>
<button
type="button"
data-test-subj="form-context-on-success"
onClick={() => onSuccess({ id: 'case-id' })}
onClick={async () => {
await onSuccess({ id: 'case-id' });
}}
>
{'submit'}
</button>
@ -55,10 +57,10 @@ jest.mock('../create/submit_button', () => {
});
const onCloseFlyout = jest.fn();
const onCaseCreated = jest.fn();
const onSuccess = jest.fn();
const defaultProps = {
onCloseFlyout,
onCaseCreated,
onSuccess,
};
describe('CreateCaseFlyout', () => {
@ -97,7 +99,7 @@ describe('CreateCaseFlyout', () => {
const props = wrapper.find('FormContext').props();
expect(props).toEqual(
expect.objectContaining({
onSuccess: onCaseCreated,
onSuccess,
})
);
});
@ -110,6 +112,6 @@ describe('CreateCaseFlyout', () => {
);
wrapper.find(`[data-test-subj='form-context-on-success']`).first().simulate('click');
expect(onCaseCreated).toHaveBeenCalledWith({ id: 'case-id' });
expect(onSuccess).toHaveBeenCalledWith({ id: 'case-id' });
});
});

View file

@ -17,7 +17,8 @@ import * as i18n from '../../translations';
export interface CreateCaseModalProps {
onCloseFlyout: () => void;
onCaseCreated: (theCase: Case) => void;
onSuccess: (theCase: Case) => Promise<void>;
afterCaseCreated?: (theCase: Case) => Promise<void>;
}
const Container = styled.div`
@ -40,7 +41,8 @@ const FormWrapper = styled.div`
`;
const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({
onCaseCreated,
onSuccess,
afterCaseCreated,
onCloseFlyout,
}) => {
return (
@ -52,7 +54,7 @@ const CreateCaseFlyoutComponent: React.FC<CreateCaseModalProps> = ({
</EuiFlyoutHeader>
<EuiFlyoutBody>
<FormWrapper>
<FormContext onSuccess={onCaseCreated}>
<FormContext onSuccess={onSuccess} afterCaseCreated={afterCaseCreated}>
<CreateCaseForm withSteps={false} />
<Container>
<SubmitCaseButton />

View file

@ -98,6 +98,7 @@ const fillForm = (wrapper: ReactWrapper) => {
describe('Create case', () => {
const fetchTags = jest.fn();
const onFormSubmitSuccess = jest.fn();
const afterCaseCreated = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
@ -593,4 +594,89 @@ describe('Create case', () => {
});
});
});
it(`it should call afterCaseCreated`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}>
<CreateCaseForm />
<SubmitCaseButton />
</FormContext>
</TestProviders>
);
fillForm(wrapper);
expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click');
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy();
});
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => {
expect(afterCaseCreated).toHaveBeenCalledWith({
id: sampleId,
...sampleData,
});
});
});
it(`it should call callbacks in correct order`, async () => {
useConnectorsMock.mockReturnValue({
...sampleConnectorData,
connectors: connectorsMock,
});
const wrapper = mount(
<TestProviders>
<FormContext onSuccess={onFormSubmitSuccess} afterCaseCreated={afterCaseCreated}>
<CreateCaseForm />
<SubmitCaseButton />
</FormContext>
</TestProviders>
);
fillForm(wrapper);
expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy();
wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click');
wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click');
await waitFor(() => {
wrapper.update();
expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy();
});
wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click');
await waitFor(() => {
expect(postCase).toHaveBeenCalled();
expect(afterCaseCreated).toHaveBeenCalled();
expect(pushCaseToExternalService).toHaveBeenCalled();
expect(onFormSubmitSuccess).toHaveBeenCalled();
});
const postCaseOrder = postCase.mock.invocationCallOrder[0];
const afterCaseOrder = afterCaseCreated.mock.invocationCallOrder[0];
const pushCaseToExternalServiceOrder = pushCaseToExternalService.mock.invocationCallOrder[0];
const onFormSubmitSuccessOrder = onFormSubmitSuccess.mock.invocationCallOrder[0];
expect(
postCaseOrder < afterCaseOrder &&
postCaseOrder < pushCaseToExternalServiceOrder &&
postCaseOrder < onFormSubmitSuccessOrder
).toBe(true);
expect(
afterCaseOrder < pushCaseToExternalServiceOrder && afterCaseOrder < onFormSubmitSuccessOrder
).toBe(true);
expect(pushCaseToExternalServiceOrder < onFormSubmitSuccessOrder).toBe(true);
});
});

View file

@ -32,13 +32,15 @@ const initialCaseValue: FormProps = {
interface Props {
caseType?: CaseType;
onSuccess?: (theCase: Case) => void;
onSuccess?: (theCase: Case) => Promise<void>;
afterCaseCreated?: (theCase: Case) => Promise<void>;
}
export const FormContext: React.FC<Props> = ({
caseType = CaseType.individual,
children,
onSuccess,
afterCaseCreated,
}) => {
const { connectors } = useConnectors();
const { connector: configurationConnector } = useCaseConfigure();
@ -72,6 +74,10 @@ export const FormContext: React.FC<Props> = ({
settings: { syncAlerts },
});
if (afterCaseCreated && updatedCase) {
await afterCaseCreated(updatedCase);
}
if (updatedCase?.id && dataConnectorId !== 'none') {
await pushCaseToExternalService({
caseId: updatedCase.id,
@ -80,11 +86,11 @@ export const FormContext: React.FC<Props> = ({
}
if (onSuccess && updatedCase) {
onSuccess(updatedCase);
await onSuccess(updatedCase);
}
}
},
[caseType, connectors, postCase, onSuccess, pushCaseToExternalService]
[caseType, connectors, postCase, onSuccess, pushCaseToExternalService, afterCaseCreated]
);
const { form } = useForm<FormProps>({

View file

@ -41,7 +41,7 @@ const InsertTimeline = () => {
export const Create = React.memo(() => {
const history = useHistory();
const onSuccess = useCallback(
({ id }) => {
async ({ id }) => {
history.push(getCaseDetailsUrl({ id }));
},
[history]

View file

@ -42,7 +42,7 @@ describe('StatusActionButton', () => {
expect(
wrapper.find(`[data-test-subj="case-view-status-action-button"]`).first().prop('iconType')
).toBe('folderClosed');
).toBe('folderCheck');
});
it('it renders the correct button icon: status closed', () => {

View file

@ -83,7 +83,7 @@ export const statuses: Statuses = {
[CaseStatuses.closed]: {
color: 'default',
label: i18n.CLOSED,
icon: 'folderClosed' as const,
icon: 'folderCheck' as const,
actions: {
bulk: {
title: i18n.BULK_ACTION_CLOSE_SELECTED,

View file

@ -30,12 +30,18 @@ jest.mock('../../../common/components/toasters', () => {
jest.mock('../all_cases', () => {
return {
AllCases: ({ onRowClick }: { onRowClick: ({ id }: { id: string }) => void }) => {
AllCases: ({ onRowClick }: { onRowClick: (theCase: Partial<Case>) => void }) => {
return (
<button
type="button"
data-test-subj="all-cases-modal-button"
onClick={() => onRowClick({ id: 'selected-case' })}
onClick={() =>
onRowClick({
id: 'selected-case',
title: 'the selected case',
settings: { syncAlerts: true },
})
}
>
{'case-row'}
</button>
@ -49,18 +55,25 @@ jest.mock('../create/form_context', () => {
FormContext: ({
children,
onSuccess,
afterCaseCreated,
}: {
children: ReactNode;
onSuccess: (theCase: Partial<Case>) => void;
onSuccess: (theCase: Partial<Case>) => Promise<void>;
afterCaseCreated: (theCase: Partial<Case>) => Promise<void>;
}) => {
return (
<>
<button
type="button"
data-test-subj="form-context-on-success"
onClick={() =>
onSuccess({ id: 'new-case', title: 'the new case', settings: { syncAlerts: true } })
}
onClick={() => {
afterCaseCreated({
id: 'new-case',
title: 'the new case',
settings: { syncAlerts: true },
});
onSuccess({ id: 'new-case', title: 'the new case', settings: { syncAlerts: true } });
}}
>
{'submit'}
</button>
@ -212,14 +225,7 @@ describe('AddToCaseAction', () => {
});
});
it('navigates to case view', async () => {
usePostCommentMock.mockImplementation(() => {
return {
...defaultPostComment,
postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()),
};
});
it('navigates to case view when attach to a new case', async () => {
const wrapper = mount(
<TestProviders>
<AddToCaseAction {...props} />
@ -244,4 +250,45 @@ describe('AddToCaseAction', () => {
expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/new-case' });
});
it('navigates to case view when attach to an existing case', async () => {
usePostCommentMock.mockImplementation(() => {
return {
...defaultPostComment,
postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => {
updateCase({
id: 'selected-case',
title: 'the selected case',
settings: { syncAlerts: true },
});
}),
};
});
const wrapper = mount(
<TestProviders>
<AddToCaseAction {...props} />
</TestProviders>
);
wrapper.find(`[data-test-subj="attach-alert-to-case-button"]`).first().simulate('click');
wrapper.find(`[data-test-subj="add-existing-case-menu-item"]`).first().simulate('click');
wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click');
expect(mockDispatchToaster).toHaveBeenCalled();
const toast = mockDispatchToaster.mock.calls[0][0].toast;
const toastWrapper = mount(
<EuiGlobalToastList toasts={[toast]} toastLifeTimeMs={6000} dismissToast={() => {}} />
);
toastWrapper
.find('[data-test-subj="toaster-content-case-view-link"]')
.first()
.simulate('click');
expect(mockNavigateToApp).toHaveBeenCalledWith('securitySolution:case', {
path: '/selected-case',
});
});
});

View file

@ -15,7 +15,7 @@ import {
EuiToolTip,
} from '@elastic/eui';
import { CommentType } from '../../../../../case/common/api';
import { CommentType, CaseStatuses } from '../../../../../case/common/api';
import { Ecs } from '../../../../common/ecs';
import { ActionIconItem } from '../../../timelines/components/timeline/body/actions/action_icon_item';
import { usePostComment } from '../../containers/use_post_comment';
@ -70,9 +70,9 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
} = useControl();
const attachAlertToCase = useCallback(
(theCase: Case) => {
async (theCase: Case, updateCase?: (newCase: Case) => void) => {
closeCaseFlyoutOpen();
postComment({
await postComment({
caseId: theCase.id,
data: {
type: CommentType.alert,
@ -83,14 +83,19 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
name: rule?.name != null ? rule.name[0] : null,
},
},
updateCase: () =>
dispatchToaster({
type: 'addToaster',
toast: createUpdateSuccessToaster(theCase, onViewCaseClick),
}),
updateCase,
});
},
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick]
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule]
);
const onCaseSuccess = useCallback(
async (theCase: Case) =>
dispatchToaster({
type: 'addToaster',
toast: createUpdateSuccessToaster(theCase, onViewCaseClick),
}),
[dispatchToaster, onViewCaseClick]
);
const onCaseClicked = useCallback(
@ -105,12 +110,13 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
return;
}
attachAlertToCase(theCase);
attachAlertToCase(theCase, onCaseSuccess);
},
[attachAlertToCase, openCaseFlyoutOpen]
[attachAlertToCase, onCaseSuccess, openCaseFlyoutOpen]
);
const { modal: allCasesModal, openModal: openAllCaseModal } = useAllCasesModal({
disabledStatuses: [CaseStatuses.closed],
onRowClick: onCaseClicked,
});
@ -183,7 +189,11 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
</EuiPopover>
</ActionIconItem>
{isCreateCaseFlyoutOpen && (
<CreateCaseFlyout onCloseFlyout={closeCaseFlyoutOpen} onCaseCreated={attachAlertToCase} />
<CreateCaseFlyout
onCloseFlyout={closeCaseFlyoutOpen}
afterCaseCreated={attachAlertToCase}
onSuccess={onCaseSuccess}
/>
)}
{allCasesModal}
</>

View file

@ -6,36 +6,52 @@
*/
import React, { memo } from 'react';
import styled from 'styled-components';
import { EuiModal, EuiModalBody, EuiModalHeader, EuiModalHeaderTitle } from '@elastic/eui';
import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana';
import { Case } from '../../containers/types';
import { CaseStatuses } from '../../../../../case/common/api';
import { Case, SubCase } from '../../containers/types';
import { AllCases } from '../all_cases';
import * as i18n from './translations';
export interface AllCasesModalProps {
isModalOpen: boolean;
onCloseCaseModal: () => void;
onRowClick: (theCase?: Case) => void;
onRowClick: (theCase?: Case | SubCase) => void;
disabledStatuses?: CaseStatuses[];
}
const Modal = styled(EuiModal)`
${({ theme }) => `
width: ${theme.eui.euiBreakpoints.l};
max-width: ${theme.eui.euiBreakpoints.l};
`}
`;
const AllCasesModalComponent: React.FC<AllCasesModalProps> = ({
isModalOpen,
onCloseCaseModal,
onRowClick,
disabledStatuses,
}) => {
const userPermissions = useGetUserSavedObjectPermissions();
const userCanCrud = userPermissions?.crud ?? false;
return isModalOpen ? (
<EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal">
<Modal onClose={onCloseCaseModal} data-test-subj="all-cases-modal">
<EuiModalHeader>
<EuiModalHeaderTitle>{i18n.SELECT_CASE_TITLE}</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<AllCases onRowClick={onRowClick} userCanCrud={userCanCrud} isModal />
<AllCases
onRowClick={onRowClick}
userCanCrud={userCanCrud}
isModal
disabledStatuses={disabledStatuses}
/>
</EuiModalBody>
</EuiModal>
</Modal>
) : null;
};

View file

@ -123,7 +123,7 @@ describe('useAllCasesModal', () => {
});
const modal = result.current.modal;
render(<>{modal}</>);
render(<TestProviders>{modal}</TestProviders>);
act(() => {
userEvent.click(screen.getByText('case-row'));

View file

@ -6,11 +6,13 @@
*/
import React, { useState, useCallback, useMemo } from 'react';
import { Case } from '../../containers/types';
import { CaseStatuses } from '../../../../../case/common/api';
import { Case, SubCase } from '../../containers/types';
import { AllCasesModal } from './all_cases_modal';
export interface UseAllCasesModalProps {
onRowClick: (theCase?: Case) => void;
onRowClick: (theCase?: Case | SubCase) => void;
disabledStatuses?: CaseStatuses[];
}
export interface UseAllCasesModalReturnedValues {
@ -22,12 +24,13 @@ export interface UseAllCasesModalReturnedValues {
export const useAllCasesModal = ({
onRowClick,
disabledStatuses,
}: UseAllCasesModalProps): UseAllCasesModalReturnedValues => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
const onClick = useCallback(
(theCase?: Case) => {
(theCase?: Case | SubCase) => {
closeModal();
onRowClick(theCase);
},
@ -41,6 +44,7 @@ export const useAllCasesModal = ({
isModalOpen={isModalOpen}
onCloseCaseModal={closeModal}
onRowClick={onClick}
disabledStatuses={disabledStatuses}
/>
),
isModalOpen,
@ -48,7 +52,7 @@ export const useAllCasesModal = ({
openModal,
onRowClick,
}),
[isModalOpen, closeModal, onClick, openModal, onRowClick]
[isModalOpen, closeModal, onClick, disabledStatuses, openModal, onRowClick]
);
return state;

View file

@ -20,14 +20,16 @@ jest.mock('../create/form_context', () => {
onSuccess,
}: {
children: ReactNode;
onSuccess: ({ id }: { id: string }) => void;
onSuccess: ({ id }: { id: string }) => Promise<void>;
}) => {
return (
<>
<button
type="button"
data-test-subj="form-context-on-success"
onClick={() => onSuccess({ id: 'case-id' })}
onClick={async () => {
await onSuccess({ id: 'case-id' });
}}
>
{'submit'}
</button>

View file

@ -19,7 +19,7 @@ import { CaseType } from '../../../../../case/common/api';
export interface CreateCaseModalProps {
isModalOpen: boolean;
onCloseCaseModal: () => void;
onSuccess: (theCase: Case) => void;
onSuccess: (theCase: Case) => Promise<void>;
caseType?: CaseType;
}

View file

@ -25,14 +25,16 @@ jest.mock('../create/form_context', () => {
onSuccess,
}: {
children: ReactNode;
onSuccess: ({ id }: { id: string }) => void;
onSuccess: ({ id }: { id: string }) => Promise<void>;
}) => {
return (
<>
<button
type="button"
data-test-subj="form-context-on-success"
onClick={() => onSuccess({ id: 'case-id' })}
onClick={async () => {
await onSuccess({ id: 'case-id' });
}}
>
{'Form submit'}
</button>

View file

@ -29,7 +29,7 @@ export const useCreateCaseModal = ({
const closeModal = useCallback(() => setIsModalOpen(false), []);
const openModal = useCallback(() => setIsModalOpen(true), []);
const onSuccess = useCallback(
(theCase) => {
async (theCase) => {
onCaseCreated(theCase);
closeModal();
},

View file

@ -55,6 +55,9 @@ describe('UserActionTree ', () => {
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ content: sampleData.content, comment: '' }]);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
jest
.spyOn(routeData, 'useParams')
.mockReturnValue({ detailName: 'case-id', subCaseId: 'sub-case-id' });
});
it('Loading spinner when user actions loading and displays fullName/username', () => {
@ -289,7 +292,8 @@ describe('UserActionTree ', () => {
).toEqual(false);
expect(patchComment).toBeCalledWith({
commentUpdate: sampleData.content,
caseId: props.data.id,
caseId: 'case-id',
subCaseId: 'sub-case-id',
commentId: props.data.comments[0].id,
fetchUserActions,
updateCase,

View file

@ -122,7 +122,11 @@ export const UserActionTree = React.memo(
userCanCrud,
onShowAlertDetails,
}: UserActionTreeProps) => {
const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>();
const { detailName: caseId, commentId, subCaseId } = useParams<{
detailName: string;
commentId?: string;
subCaseId?: string;
}>();
const handlerTimeoutId = useRef(0);
const addCommentRef = useRef<AddCommentRefObject>(null);
const [initLoading, setInitLoading] = useState(true);
@ -149,15 +153,16 @@ export const UserActionTree = React.memo(
const handleSaveComment = useCallback(
({ id, version }: { id: string; version: string }, content: string) => {
patchComment({
caseId: caseData.id,
caseId,
commentId: id,
commentUpdate: content,
fetchUserActions,
version,
updateCase,
subCaseId,
});
},
[caseData.id, fetchUserActions, patchComment, updateCase]
[caseId, fetchUserActions, patchComment, subCaseId, updateCase]
);
const handleOutlineComment = useCallback(
@ -223,7 +228,7 @@ export const UserActionTree = React.memo(
const MarkdownNewComment = useMemo(
() => (
<AddComment
caseId={caseData.id}
caseId={caseId}
disabled={!userCanCrud}
ref={addCommentRef}
onCommentPosted={handleUpdate}
@ -232,7 +237,7 @@ export const UserActionTree = React.memo(
subCaseId={subCaseId}
/>
),
[caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId]
[caseId, userCanCrud, handleUpdate, handleManageMarkdownEditId, subCaseId]
);
useEffect(() => {

View file

@ -6,7 +6,7 @@
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { initialData, useGetCase, UseGetCase } from './use_get_case';
import { useGetCase, UseGetCase } from './use_get_case';
import { basicCase } from './mock';
import * as api from './api';
@ -26,8 +26,8 @@ describe('useGetCase', () => {
);
await waitForNextUpdate();
expect(result.current).toEqual({
data: initialData,
isLoading: true,
data: null,
isLoading: false,
isError: false,
fetchCase: result.current.fetchCase,
updateCase: result.current.updateCase,
@ -102,7 +102,7 @@ describe('useGetCase', () => {
await waitForNextUpdate();
expect(result.current).toEqual({
data: initialData,
data: null,
isLoading: false,
isError: true,
fetchCase: result.current.fetchCase,

View file

@ -6,16 +6,14 @@
*/
import { useEffect, useReducer, useCallback, useRef } from 'react';
import { CaseStatuses, CaseType } from '../../../../case/common/api';
import { Case } from './types';
import * as i18n from './translations';
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
import { getCase, getSubCase } from './api';
import { getNoneConnector } from '../components/configure_cases/utils';
interface CaseState {
data: Case;
data: Case | null;
isLoading: boolean;
isError: boolean;
}
@ -56,32 +54,6 @@ const dataFetchReducer = (state: CaseState, action: Action): CaseState => {
return state;
}
};
export const initialData: Case = {
id: '',
closedAt: null,
closedBy: null,
createdAt: '',
comments: [],
connector: { ...getNoneConnector(), fields: null },
createdBy: {
username: '',
},
description: '',
externalService: null,
status: CaseStatuses.open,
tags: [],
title: '',
totalAlerts: 0,
totalComment: 0,
type: CaseType.individual,
updatedAt: null,
updatedBy: null,
version: '',
subCaseIds: [],
settings: {
syncAlerts: true,
},
};
export interface UseGetCase extends CaseState {
fetchCase: () => void;
@ -90,9 +62,9 @@ export interface UseGetCase extends CaseState {
export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: true,
isLoading: false,
isError: false,
data: initialData,
data: null,
});
const [, dispatchToaster] = useStateToaster();
const isCancelledRef = useRef(false);

View file

@ -48,7 +48,7 @@ interface PostComment {
subCaseId?: string;
}
export interface UsePostComment extends NewCommentState {
postComment: (args: PostComment) => void;
postComment: (args: PostComment) => Promise<void>;
}
export const usePostComment = (): UsePostComment => {

View file

@ -294,3 +294,10 @@ export const ALERT_ADDED_TO_CASE = i18n.translate(
defaultMessage: 'added to case',
}
);
export const SELECTABLE_MESSAGE_COLLECTIONS = i18n.translate(
'xpack.securitySolution.common.allCases.table.selectableMessageCollections',
{
defaultMessage: 'Cases with sub-cases cannot be selected',
}
);

View file

@ -25,7 +25,7 @@ describe('useQueryAlerts', () => {
>(() => useQueryAlerts<unknown, unknown>(mockAlertsQuery, indexName));
await waitForNextUpdate();
expect(result.current).toEqual({
loading: true,
loading: false,
data: null,
response: '',
request: '',

View file

@ -42,7 +42,7 @@ export const useQueryAlerts = <Hit, Aggs>(
setQuery,
refetch: null,
});
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => {
let isSubscribed = true;

View file

@ -20,7 +20,7 @@ import { TimelineStatus, TimelineId, TimelineType } from '../../../../../common/
import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to';
import { SecurityPageName } from '../../../../app/types';
import { timelineDefaults } from '../../../../timelines/store/timeline/defaults';
import { Case } from '../../../../cases/containers/types';
import { Case, SubCase } from '../../../../cases/containers/types';
import * as i18n from '../../timeline/properties/translations';
interface Props {
@ -46,7 +46,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ timelineId }) => {
const [isPopoverOpen, setPopover] = useState(false);
const onRowClick = useCallback(
async (theCase?: Case) => {
async (theCase?: Case | SubCase) => {
await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(),
});

View file

@ -101,15 +101,15 @@ export default ({ getService }: FtrProviderContext): void => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.delete(
`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}?subCaseID=${
caseInfo.subCase!.id
`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}?subCaseId=${
caseInfo.subCases![0].id
}`
)
.set('kbn-xsrf', 'true')
.send()
.expect(204);
const { body } = await supertest.get(
`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`
`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`
);
expect(body.length).to.eql(0);
});
@ -117,24 +117,24 @@ export default ({ getService }: FtrProviderContext): void => {
it('deletes all comments from a sub case', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send(postCommentUserReq)
.expect(200);
let { body: allComments } = await supertest.get(
`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`
`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`
);
expect(allComments.length).to.eql(2);
await supertest
.delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.delete(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);
({ body: allComments } = await supertest.get(
`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`
`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`
));
// no comments for the sub case

View file

@ -126,13 +126,13 @@ export default ({ getService }: FtrProviderContext): void => {
it('finds comments for a sub case', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send(postCommentUserReq)
.expect(200);
const { body: subCaseComments }: { body: CommentsResponse } = await supertest
.get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`)
.get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`)
.send()
.expect(200);
expect(subCaseComments.total).to.be(2);

View file

@ -83,13 +83,13 @@ export default ({ getService }: FtrProviderContext): void => {
it('should get comments from a sub cases', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.post(`${CASES_URL}/${caseInfo.subCase!.id}/comments`)
.post(`${CASES_URL}/${caseInfo.subCases![0].id}/comments`)
.set('kbn-xsrf', 'true')
.send(postCommentUserReq)
.expect(200);
const { body: comments } = await supertest
.get(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.get(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.expect(200);
expect(comments.length).to.eql(2);

View file

@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should get a sub case comment', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
const { body: comment }: { body: CommentResponse } = await supertest
.get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.subCase!.comments![0].id}`)
.get(`${CASES_URL}/${caseInfo.id}/comments/${caseInfo.comments![0].id}`)
.expect(200);
expect(comment.type).to.be(CommentType.generatedAlert);
});

View file

@ -10,10 +10,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { CASES_URL } from '../../../../../../plugins/case/common/constants';
import {
CollectionWithSubCaseResponse,
CommentType,
} from '../../../../../../plugins/case/common/api';
import { CaseResponse, CommentType } from '../../../../../../plugins/case/common/api';
import {
defaultUser,
postCaseReq,
@ -56,42 +53,38 @@ export default ({ getService }: FtrProviderContext): void => {
it('patches a comment for a sub case', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
const {
body: patchedSubCase,
}: { body: CollectionWithSubCaseResponse } = await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
const { body: patchedSubCase }: { body: CaseResponse } = await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send(postCommentUserReq)
.expect(200);
const newComment = 'Well I decided to update my comment. So what? Deal with it.';
const { body: patchedSubCaseUpdatedComment } = await supertest
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send({
id: patchedSubCase.subCase!.comments![1].id,
version: patchedSubCase.subCase!.comments![1].version,
id: patchedSubCase.comments![1].id,
version: patchedSubCase.comments![1].version,
comment: newComment,
type: CommentType.user,
})
.expect(200);
expect(patchedSubCaseUpdatedComment.subCase.comments.length).to.be(2);
expect(patchedSubCaseUpdatedComment.subCase.comments[0].type).to.be(
CommentType.generatedAlert
);
expect(patchedSubCaseUpdatedComment.subCase.comments[1].type).to.be(CommentType.user);
expect(patchedSubCaseUpdatedComment.subCase.comments[1].comment).to.be(newComment);
expect(patchedSubCaseUpdatedComment.comments.length).to.be(2);
expect(patchedSubCaseUpdatedComment.comments[0].type).to.be(CommentType.generatedAlert);
expect(patchedSubCaseUpdatedComment.comments[1].type).to.be(CommentType.user);
expect(patchedSubCaseUpdatedComment.comments[1].comment).to.be(newComment);
});
it('fails to update the generated alert comment type', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send({
id: caseInfo.subCase!.comments![0].id,
version: caseInfo.subCase!.comments![0].version,
id: caseInfo.comments![0].id,
version: caseInfo.comments![0].version,
type: CommentType.alert,
alertId: 'test-id',
index: 'test-index',
@ -106,11 +99,11 @@ export default ({ getService }: FtrProviderContext): void => {
it('fails to update the generated alert comment by using another generated alert comment', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
await supertest
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.patch(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send({
id: caseInfo.subCase!.comments![0].id,
version: caseInfo.subCase!.comments![0].version,
id: caseInfo.comments![0].id,
version: caseInfo.comments![0].version,
type: CommentType.generatedAlert,
alerts: [{ _id: 'id1' }],
index: 'test-index',

View file

@ -393,13 +393,13 @@ export default ({ getService }: FtrProviderContext): void => {
// create another sub case just to make sure we get the right comments
await createSubCase({ supertest, actionID });
await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseID=${caseInfo.subCase!.id}`)
.post(`${CASES_URL}/${caseInfo.id}/comments?subCaseId=${caseInfo.subCases![0].id}`)
.set('kbn-xsrf', 'true')
.send(postCommentUserReq)
.expect(200);
const { body: subCaseComments }: { body: CommentsResponse } = await supertest
.get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseID=${caseInfo.subCase!.id}`)
.get(`${CASES_URL}/${caseInfo.id}/comments/_find?subCaseId=${caseInfo.subCases![0].id}`)
.send()
.expect(200);
expect(subCaseComments.total).to.be(2);

View file

@ -20,7 +20,7 @@ import {
deleteComments,
} from '../../../common/lib/utils';
import { getSubCaseDetailsUrl } from '../../../../../plugins/case/common/api/helpers';
import { CollectionWithSubCaseResponse } from '../../../../../plugins/case/common/api';
import { CaseResponse } from '../../../../../plugins/case/common/api';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext): void => {
@ -104,7 +104,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should delete the sub cases when deleting a collection', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
expect(caseInfo.subCase?.id).to.not.eql(undefined);
expect(caseInfo.subCases![0].id).to.not.eql(undefined);
const { body } = await supertest
.delete(`${CASES_URL}?ids=["${caseInfo.id}"]`)
@ -114,27 +114,25 @@ export default ({ getService }: FtrProviderContext): void => {
expect(body).to.eql({});
await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.send()
.expect(404);
});
it(`should delete a sub case's comments when that case gets deleted`, async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
expect(caseInfo.subCase?.id).to.not.eql(undefined);
expect(caseInfo.subCases![0].id).to.not.eql(undefined);
// there should be two comments on the sub case now
const {
body: patchedCaseWithSubCase,
}: { body: CollectionWithSubCaseResponse } = await supertest
const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments`)
.set('kbn-xsrf', 'true')
.query({ subCaseID: caseInfo.subCase!.id })
.query({ subCaseId: caseInfo.subCases![0].id })
.send(postCommentUserReq)
.expect(200);
const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${
patchedCaseWithSubCase.subCase!.comments![1].id
patchedCaseWithSubCase.comments![1].id
}`;
// make sure we can get the second comment
await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200);

View file

@ -265,8 +265,8 @@ export default ({ getService }: FtrProviderContext): void => {
supertest,
cases: [
{
id: collection.newSubCaseInfo.subCase!.id,
version: collection.newSubCaseInfo.subCase!.version,
id: collection.newSubCaseInfo.subCases![0].id,
version: collection.newSubCaseInfo.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -356,7 +356,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('correctly counts stats including a collection without sub cases', async () => {
// delete the sub case on the collection so that it doesn't have any sub cases
await supertest
.delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCase!.id}"]`)
.delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${collection.newSubCaseInfo.subCases![0].id}"]`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);

View file

@ -19,7 +19,7 @@ import {
deleteCaseAction,
} from '../../../../common/lib/utils';
import { getSubCaseDetailsUrl } from '../../../../../../plugins/case/common/api/helpers';
import { CollectionWithSubCaseResponse } from '../../../../../../plugins/case/common/api';
import { CaseResponse } from '../../../../../../plugins/case/common/api';
// eslint-disable-next-line import/no-default-export
export default function ({ getService }: FtrProviderContext) {
@ -40,10 +40,10 @@ export default function ({ getService }: FtrProviderContext) {
it('should delete a sub case', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
expect(caseInfo.subCase?.id).to.not.eql(undefined);
expect(caseInfo.subCases![0].id).to.not.eql(undefined);
const { body: subCase } = await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.send()
.expect(200);
@ -57,33 +57,31 @@ export default function ({ getService }: FtrProviderContext) {
expect(body).to.eql({});
await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.send()
.expect(404);
});
it(`should delete a sub case's comments when that case gets deleted`, async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
expect(caseInfo.subCase?.id).to.not.eql(undefined);
expect(caseInfo.subCases![0].id).to.not.eql(undefined);
// there should be two comments on the sub case now
const {
body: patchedCaseWithSubCase,
}: { body: CollectionWithSubCaseResponse } = await supertest
const { body: patchedCaseWithSubCase }: { body: CaseResponse } = await supertest
.post(`${CASES_URL}/${caseInfo.id}/comments`)
.set('kbn-xsrf', 'true')
.query({ subCaseID: caseInfo.subCase!.id })
.query({ subCaseId: caseInfo.subCases![0].id })
.send(postCommentUserReq)
.expect(200);
const subCaseCommentUrl = `${CASES_URL}/${patchedCaseWithSubCase.id}/comments/${
patchedCaseWithSubCase.subCase!.comments![1].id
patchedCaseWithSubCase.comments![1].id
}`;
// make sure we can get the second comment
await supertest.get(subCaseCommentUrl).set('kbn-xsrf', 'true').send().expect(200);
await supertest
.delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCase!.id}"]`)
.delete(`${SUB_CASES_PATCH_DEL_URL}?ids=["${patchedCaseWithSubCase.subCases![0].id}"]`)
.set('kbn-xsrf', 'true')
.send()
.expect(204);

View file

@ -74,7 +74,7 @@ export default ({ getService }: FtrProviderContext): void => {
...findSubCasesResp,
total: 1,
// find should not return the comments themselves only the stats
subCases: [{ ...caseInfo.subCase!, comments: [], totalComment: 1, totalAlerts: 2 }],
subCases: [{ ...caseInfo.subCases![0], comments: [], totalComment: 1, totalAlerts: 2 }],
count_open_cases: 1,
});
});
@ -101,7 +101,7 @@ export default ({ getService }: FtrProviderContext): void => {
status: CaseStatuses.closed,
},
{
...subCase2Resp.newSubCaseInfo.subCase,
...subCase2Resp.newSubCaseInfo.subCases![0],
comments: [],
totalComment: 1,
totalAlerts: 2,
@ -157,8 +157,8 @@ export default ({ getService }: FtrProviderContext): void => {
supertest,
cases: [
{
id: secondSub.subCase!.id,
version: secondSub.subCase!.version,
id: secondSub.subCases![0].id,
version: secondSub.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -231,8 +231,8 @@ export default ({ getService }: FtrProviderContext): void => {
supertest,
cases: [
{
id: secondSub.subCase!.id,
version: secondSub.subCase!.version,
id: secondSub.subCases![0].id,
version: secondSub.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],

View file

@ -28,7 +28,7 @@ import {
} from '../../../../../../plugins/case/common/api/helpers';
import {
AssociationType,
CollectionWithSubCaseResponse,
CaseResponse,
SubCaseResponse,
} from '../../../../../../plugins/case/common/api';
@ -53,14 +53,14 @@ export default ({ getService }: FtrProviderContext): void => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
const { body }: { body: SubCaseResponse } = await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.set('kbn-xsrf', 'true')
.send()
.expect(200);
expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql(
commentsResp({
comments: [{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id }],
comments: [{ comment: defaultCreateSubComment, id: caseInfo.comments![0].id }],
associationType: AssociationType.subCase,
})
);
@ -73,15 +73,15 @@ export default ({ getService }: FtrProviderContext): void => {
it('should return the correct number of alerts with multiple types of alerts', async () => {
const { newSubCaseInfo: caseInfo } = await createSubCase({ supertest, actionID });
const { body: singleAlert }: { body: CollectionWithSubCaseResponse } = await supertest
const { body: singleAlert }: { body: CaseResponse } = await supertest
.post(getCaseCommentsUrl(caseInfo.id))
.query({ subCaseID: caseInfo.subCase!.id })
.query({ subCaseId: caseInfo.subCases![0].id })
.set('kbn-xsrf', 'true')
.send(postCommentAlertReq)
.expect(200);
const { body }: { body: SubCaseResponse } = await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.set('kbn-xsrf', 'true')
.send()
.expect(200);
@ -89,10 +89,10 @@ export default ({ getService }: FtrProviderContext): void => {
expect(removeServerGeneratedPropertiesFromComments(body.comments)).to.eql(
commentsResp({
comments: [
{ comment: defaultCreateSubComment, id: caseInfo.subCase!.comments![0].id },
{ comment: defaultCreateSubComment, id: caseInfo.comments![0].id },
{
comment: postCommentAlertReq,
id: singleAlert.subCase!.comments![1].id,
id: singleAlert.comments![1].id,
},
],
associationType: AssociationType.subCase,

View file

@ -59,15 +59,15 @@ export default function ({ getService }: FtrProviderContext) {
supertest,
cases: [
{
id: caseInfo.subCase!.id,
version: caseInfo.subCase!.version,
id: caseInfo.subCases![0].id,
version: caseInfo.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
type: 'sub_case',
});
const { body: subCase }: { body: SubCaseResponse } = await supertest
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCase!.id))
.get(getSubCaseDetailsUrl(caseInfo.id, caseInfo.subCases![0].id))
.expect(200);
expect(subCase.status).to.eql(CaseStatuses['in-progress']);
@ -102,8 +102,8 @@ export default function ({ getService }: FtrProviderContext) {
supertest,
cases: [
{
id: caseInfo.subCase!.id,
version: caseInfo.subCase!.version,
id: caseInfo.subCases![0].id,
version: caseInfo.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -159,8 +159,8 @@ export default function ({ getService }: FtrProviderContext) {
supertest,
cases: [
{
id: caseInfo.subCase!.id,
version: caseInfo.subCase!.version,
id: caseInfo.subCases![0].id,
version: caseInfo.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -239,8 +239,8 @@ export default function ({ getService }: FtrProviderContext) {
supertest,
cases: [
{
id: collectionWithSecondSub.subCase!.id,
version: collectionWithSecondSub.subCase!.version,
id: collectionWithSecondSub.subCases![0].id,
version: collectionWithSecondSub.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -349,8 +349,8 @@ export default function ({ getService }: FtrProviderContext) {
supertest,
cases: [
{
id: caseInfo.subCase!.id,
version: caseInfo.subCase!.version,
id: caseInfo.subCases![0].id,
version: caseInfo.subCases![0].version,
status: CaseStatuses['in-progress'],
},
],
@ -450,8 +450,8 @@ export default function ({ getService }: FtrProviderContext) {
.send({
subCases: [
{
id: caseInfo.subCase!.id,
version: caseInfo.subCase!.version,
id: caseInfo.subCases![0].id,
version: caseInfo.subCases![0].version,
type: 'blah',
},
],

View file

@ -26,7 +26,6 @@ import {
CaseClientPostRequest,
SubCaseResponse,
AssociationType,
CollectionWithSubCaseResponse,
SubCasesFindResponse,
CommentRequest,
} from '../../../../plugins/case/common/api';
@ -159,18 +158,17 @@ export const subCaseResp = ({
interface FormattedCollectionResponse {
caseInfo: Partial<CaseResponse>;
subCase?: Partial<SubCaseResponse>;
subCases?: Array<Partial<SubCaseResponse>>;
comments?: Array<Partial<CommentResponse>>;
}
export const formatCollectionResponse = (
caseInfo: CollectionWithSubCaseResponse
): FormattedCollectionResponse => {
export const formatCollectionResponse = (caseInfo: CaseResponse): FormattedCollectionResponse => {
const subCase = removeServerGeneratedPropertiesFromSubCase(caseInfo.subCases?.[0]);
return {
caseInfo: removeServerGeneratedPropertiesFromCaseCollection(caseInfo),
subCase: removeServerGeneratedPropertiesFromSubCase(caseInfo.subCase),
subCases: subCase ? [subCase] : undefined,
comments: removeServerGeneratedPropertiesFromComments(
caseInfo.subCase?.comments ?? caseInfo.comments
caseInfo.subCases?.[0].comments ?? caseInfo.comments
),
};
};
@ -187,10 +185,10 @@ export const removeServerGeneratedPropertiesFromSubCase = (
};
export const removeServerGeneratedPropertiesFromCaseCollection = (
config: Partial<CollectionWithSubCaseResponse>
): Partial<CollectionWithSubCaseResponse> => {
config: Partial<CaseResponse>
): Partial<CaseResponse> => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { closed_at, created_at, updated_at, version, subCase, ...rest } = config;
const { closed_at, created_at, updated_at, version, subCases, ...rest } = config;
return rest;
};

View file

@ -17,7 +17,7 @@ import {
CaseConnector,
ConnectorTypes,
CasePostRequest,
CollectionWithSubCaseResponse,
CaseResponse,
SubCasesFindResponse,
CaseStatuses,
SubCasesResponse,
@ -120,7 +120,7 @@ export const defaultCreateSubPost = postCollectionReq;
* Response structure for the createSubCase and createSubCaseComment functions.
*/
export interface CreateSubCaseResp {
newSubCaseInfo: CollectionWithSubCaseResponse;
newSubCaseInfo: CaseResponse;
modifiedSubCases?: SubCasesResponse;
}