[Response Ops][Cases] Quit using legacy API to fetch comments (#203455)

## Summary

In order to stop using `includeComments` to load the updated data
belonging to the comments/user actions in the cases detail page we
implemented a new internal [`find user
actions`](https://github.com/elastic/kibana/pull/203455/files#diff-6b8d3c46675fe8f130e37afea148107012bb914a5f82eb277cb2448aba78de29)
API. This new API does the same as the public one + an extra step. This
extra step is fetching all the attachments by commentId, in here we will
have all updates to previous comments, etc. The rest of the PR is
updating the case detail page to work with this new schema + test fixing

Closes https://github.com/elastic/kibana/issues/194290

---------

Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julian Gernun 2025-01-17 09:19:27 +01:00 committed by GitHub
parent 4f59641f3a
commit c3ea94c554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1831 additions and 292 deletions

View file

@ -14,7 +14,7 @@ import {
CASE_CONFIGURE_DETAILS_URL,
CASE_ALERTS_URL,
CASE_COMMENT_DELETE_URL,
CASE_FIND_USER_ACTIONS_URL,
INTERNAL_CASE_FIND_USER_ACTIONS_URL,
INTERNAL_GET_CASE_USER_ACTIONS_STATS_URL,
INTERNAL_BULK_GET_ATTACHMENTS_URL,
INTERNAL_CONNECTORS_URL,
@ -57,7 +57,7 @@ export const getCaseUserActionStatsUrl = (id: string): string => {
};
export const getCaseFindUserActionsUrl = (id: string): string => {
return CASE_FIND_USER_ACTIONS_URL.replace('{case_id}', id);
return INTERNAL_CASE_FIND_USER_ACTIONS_URL.replace('{case_id}', id);
};
export const getCasePushUrl = (caseId: string, connectorId: string): string => {

View file

@ -92,6 +92,8 @@ export const INTERNAL_CASE_OBSERVABLES_PATCH_URL =
`${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const;
export const INTERNAL_CASE_OBSERVABLES_DELETE_URL =
`${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const;
export const INTERNAL_CASE_FIND_USER_ACTIONS_URL =
`${CASES_INTERNAL_URL}/{case_id}/user_actions/_find` as const;
/**
* Action routes

View file

@ -15,6 +15,7 @@ import {
CaseUserActionBasicRt,
UserActionsRt,
} from '../../domain/user_action/v1';
import type { Attachments } from '../../domain';
export type UserActionWithResponse<T> = T & { id: string; version: string } & rt.TypeOf<
typeof CaseUserActionInjectedIdsRt
@ -85,3 +86,7 @@ export const UserActionFindResponseRt = rt.strict({
});
export type UserActionFindResponse = rt.TypeOf<typeof UserActionFindResponseRt>;
export interface UserActionInternalFindResponse extends UserActionFindResponse {
latestAttachments: Attachments;
}

View file

@ -295,6 +295,15 @@ export type PersistableStateAttachmentAttributes = rt.TypeOf<
* Common
*/
export const AttachmentPayloadRt = rt.union([
UserCommentAttachmentPayloadRt,
AlertAttachmentPayloadRt,
ActionsAttachmentPayloadRt,
ExternalReferenceNoSOAttachmentPayloadRt,
ExternalReferenceSOAttachmentPayloadRt,
PersistableStateAttachmentPayloadRt,
]);
export const AttachmentAttributesRt = rt.union([
UserCommentAttachmentAttributesRt,
AlertAttachmentAttributesRt,

View file

@ -6,10 +6,12 @@
*/
import * as rt from 'io-ts';
import { AttachmentRequestRt, AttachmentRequestWithoutRefsRt } from '../../../api/attachment/v1';
import { AttachmentRequestWithoutRefsRt } from '../../../api/attachment/v1';
import { UserActionTypes } from '../action/v1';
import { AttachmentPayloadRt } from '../../attachment/v1';
export const CommentUserActionPayloadRt = rt.strict({ comment: AttachmentPayloadRt });
export const CommentUserActionPayloadRt = rt.strict({ comment: AttachmentRequestRt });
export const CommentUserActionPayloadWithoutIdsRt = rt.strict({
comment: AttachmentRequestWithoutRefsRt,
});

View file

@ -97,6 +97,11 @@ export type UserActionUI = SnakeToCamelCase<UserAction>;
export type FindCaseUserActions = Omit<SnakeToCamelCase<UserActionFindResponse>, 'userActions'> & {
userActions: UserActionUI[];
};
export interface InternalFindCaseUserActions extends FindCaseUserActions {
latestAttachments: AttachmentUI[];
}
export type CaseUserActionsStats = SnakeToCamelCase<CaseUserActionStatsResponse>;
export type CaseUI = Omit<SnakeToCamelCase<CaseSnakeCase>, 'comments'> & {
comments: AttachmentUI[];

View file

@ -38,7 +38,7 @@ import { useGetCaseUserActionsStats } from '../../../containers/use_get_case_use
import { useInfiniteFindCaseUserActions } from '../../../containers/use_infinite_find_case_user_actions';
import { useOnUpdateField } from '../use_on_update_field';
import { useCasesFeatures } from '../../../common/use_cases_features';
import { ConnectorTypes, UserActionTypes } from '../../../../common/types/domain';
import { AttachmentType, ConnectorTypes, UserActionTypes } from '../../../../common/types/domain';
import { CaseMetricsFeature } from '../../../../common/types/api';
import { useGetCaseConfiguration } from '../../../containers/configure/use_get_case_configuration';
import { useGetCurrentUserProfile } from '../../../containers/user_profiles/use_get_current_user_profile';
@ -543,6 +543,14 @@ describe('Case View Page activity tab', () => {
});
it('renders the user action users correctly', async () => {
const commentUpdate = getUserAction('comment', 'update', {
createdBy: {
...caseUsers.participants[1].user,
fullName: caseUsers.participants[1].user.full_name,
profileUid: caseUsers.participants[1].uid,
},
});
useFindCaseUserActionsMock.mockReturnValue({
...defaultUseFindCaseUserActions,
data: {
@ -555,13 +563,7 @@ describe('Case View Page activity tab', () => {
profileUid: caseUsers.participants[0].uid,
},
}),
getUserAction('comment', 'update', {
createdBy: {
...caseUsers.participants[1].user,
fullName: caseUsers.participants[1].user.full_name,
profileUid: caseUsers.participants[1].uid,
},
}),
commentUpdate,
getUserAction('description', 'update', {
createdBy: {
...caseUsers.participants[2].user,
@ -584,6 +586,25 @@ describe('Case View Page activity tab', () => {
},
}),
],
latestAttachments:
commentUpdate.type === 'comment' &&
commentUpdate.payload.comment?.type === AttachmentType.user
? [
{
comment: commentUpdate.payload.comment.comment,
createdAt: commentUpdate.createdAt,
createdBy: commentUpdate.createdBy,
id: commentUpdate.commentId,
owner: commentUpdate.owner,
pushed_at: null,
pushed_by: null,
type: 'user',
updated_at: null,
updated_by: null,
version: commentUpdate.version,
},
]
: [],
},
});

View file

@ -13,7 +13,7 @@ import {
caseUserActions,
getAlertUserAction,
} from '../../containers/mock';
import type { CaseUI } from '../../containers/types';
import type { CaseUI, UserActionUI } from '../../containers/types';
import type { CaseViewProps } from './types';
export const alertsHit = [
@ -99,8 +99,51 @@ export const defaultUpdateCaseState = {
mutate: jest.fn(),
};
const generateLatestAttachments = ({
userActions,
overrides,
}: {
userActions: UserActionUI[];
overrides: Array<{ commentId: string; comment: string }>;
}) => {
return userActions
.filter(
(
userAction
): userAction is UserActionUI & {
type: 'comment';
payload: { comment: { comment: string } };
} => userAction.type === 'comment' && Boolean(userAction.commentId)
)
.map((userAction) => {
const override = overrides.find(({ commentId }) => commentId === userAction.commentId);
return {
comment: override ? override.comment : userAction.payload.comment?.comment,
createdAt: userAction.createdAt,
createdBy: userAction.createdBy,
id: userAction.commentId,
owner: userAction.owner,
pushed_at: null,
pushed_by: null,
type: 'user',
updated_at: null,
updated_by: null,
version: userAction.version,
};
});
};
export const defaultUseFindCaseUserActions = {
data: { total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] },
data: {
total: 4,
perPage: 10,
page: 1,
userActions: [...caseUserActions, getAlertUserAction()],
latestAttachments: generateLatestAttachments({
userActions: [...caseUserActions, getAlertUserAction()],
overrides: [{ commentId: 'basic-comment-id', comment: 'Solve this fast!' }],
}),
},
refetch: jest.fn(),
isLoading: false,
isFetching: false,
@ -110,7 +153,13 @@ export const defaultUseFindCaseUserActions = {
export const defaultInfiniteUseFindCaseUserActions = {
data: {
pages: [
{ total: 4, perPage: 10, page: 1, userActions: [...caseUserActions, getAlertUserAction()] },
{
total: 4,
perPage: 10,
page: 1,
userActions: [...caseUserActions, getAlertUserAction()],
latestAttachments: [],
},
],
},
isLoading: false,

View file

@ -21,29 +21,32 @@ type BuilderArgs = Pick<
UserActionBuilderArgs,
'userAction' | 'actionsNavigation' | 'userProfiles'
> & {
comment: SnakeToCamelCase<ActionsAttachment>;
attachment: SnakeToCamelCase<ActionsAttachment>;
};
export const createActionAttachmentUserActionBuilder = ({
userAction,
userProfiles,
comment,
attachment,
actionsNavigation,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => {
const actionIconName = comment.actions.type === 'isolate' ? 'lock' : 'lockOpen';
const actionIconName = attachment.actions.type === 'isolate' ? 'lock' : 'lockOpen';
return [
{
username: (
<HoverableUserWithAvatarResolver user={comment.createdBy} userProfiles={userProfiles} />
<HoverableUserWithAvatarResolver
user={attachment.createdBy}
userProfiles={userProfiles}
/>
),
className: classNames('comment-action', {
'empty-comment': comment.comment.trim().length === 0,
'empty-comment': attachment.comment.trim().length === 0,
}),
event: (
<HostIsolationCommentEvent
type={comment.actions.type}
endpoints={comment.actions.targets}
type={attachment.actions.type}
endpoints={attachment.actions.targets}
href={actionsNavigation?.href}
onClick={actionsNavigation?.onClick}
/>
@ -52,9 +55,9 @@ export const createActionAttachmentUserActionBuilder = ({
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineAvatar: actionIconName,
timelineAvatarAriaLabel: actionIconName,
actions: <UserActionCopyLink id={comment.id} />,
children: comment.comment.trim().length > 0 && (
<ScrollableMarkdown content={comment.comment} />
actions: <UserActionCopyLink id={attachment.id} />,
children: attachment.comment.trim().length > 0 && (
<ScrollableMarkdown content={attachment.comment} />
),
},
];

View file

@ -34,12 +34,12 @@ type BuilderArgs = Pick<
| 'userProfiles'
| 'handleDeleteComment'
| 'loadingCommentIds'
> & { comment: SnakeToCamelCase<AlertAttachment> };
> & { attachment: SnakeToCamelCase<AlertAttachment> };
const getSingleAlertUserAction = ({
userAction,
userProfiles,
comment,
attachment,
alertData,
loadingAlertData,
loadingCommentIds,
@ -48,16 +48,16 @@ const getSingleAlertUserAction = ({
onShowAlertDetails,
handleDeleteComment,
}: BuilderArgs): EuiCommentProps[] => {
const alertId = getNonEmptyField(comment.alertId);
const alertIndex = getNonEmptyField(comment.index);
const alertId = getNonEmptyField(attachment.alertId);
const alertIndex = getNonEmptyField(attachment.index);
if (!alertId || !alertIndex) {
return [];
}
const alertField: unknown | undefined = alertData[alertId];
const ruleId = getRuleId(comment, alertField);
const ruleName = getRuleName(comment, alertField);
const ruleId = getRuleId(attachment, alertField);
const ruleName = getRuleName(attachment, alertField);
return [
{
@ -79,7 +79,7 @@ const getSingleAlertUserAction = ({
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineAvatar: 'bell',
actions: (
<UserActionContentToolbar id={comment.id}>
<UserActionContentToolbar id={attachment.id}>
<EuiFlexItem grow={false}>
<UserActionShowAlert
id={userAction.id}
@ -89,8 +89,8 @@ const getSingleAlertUserAction = ({
/>
</EuiFlexItem>
<AlertPropertyActions
onDelete={() => handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(1))}
isLoading={loadingCommentIds.includes(comment.id)}
onDelete={() => handleDeleteComment(attachment.id, DELETE_ALERTS_SUCCESS_TITLE(1))}
isLoading={loadingCommentIds.includes(attachment.id)}
totalAlerts={1}
/>
</UserActionContentToolbar>
@ -102,7 +102,7 @@ const getSingleAlertUserAction = ({
const getMultipleAlertsUserAction = ({
userAction,
userProfiles,
comment,
attachment,
alertData,
loadingAlertData,
loadingCommentIds,
@ -110,12 +110,12 @@ const getMultipleAlertsUserAction = ({
onRuleDetailsClick,
handleDeleteComment,
}: BuilderArgs): EuiCommentProps[] => {
if (!Array.isArray(comment.alertId)) {
if (!Array.isArray(attachment.alertId)) {
return [];
}
const totalAlerts = comment.alertId.length;
const { ruleId, ruleName } = getRuleInfo(comment, alertData);
const totalAlerts = attachment.alertId.length;
const { ruleId, ruleName } = getRuleInfo(attachment, alertData);
return [
{
@ -138,15 +138,15 @@ const getMultipleAlertsUserAction = ({
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineAvatar: 'bell',
actions: (
<UserActionContentToolbar id={comment.id}>
<UserActionContentToolbar id={attachment.id}>
<EuiFlexItem grow={false}>
<ShowAlertTableLink />
</EuiFlexItem>
<AlertPropertyActions
onDelete={() =>
handleDeleteComment(comment.id, DELETE_ALERTS_SUCCESS_TITLE(totalAlerts))
handleDeleteComment(attachment.id, DELETE_ALERTS_SUCCESS_TITLE(totalAlerts))
}
isLoading={loadingCommentIds.includes(comment.id)}
isLoading={loadingCommentIds.includes(attachment.id)}
totalAlerts={totalAlerts}
/>
</UserActionContentToolbar>
@ -159,8 +159,8 @@ export const createAlertAttachmentUserActionBuilder = (
params: BuilderArgs
): ReturnType<UserActionBuilder> => ({
build: () => {
const { comment } = params;
const alertId = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId];
const { attachment } = params;
const alertId = Array.isArray(attachment.alertId) ? attachment.alertId : [attachment.alertId];
if (alertId.length === 1) {
return getSingleAlertUserAction(params);
@ -174,35 +174,41 @@ const getFirstItem = (items?: string | string[] | null): string | null => {
return Array.isArray(items) ? items[0] : items ?? null;
};
export const getRuleId = (comment: BuilderArgs['comment'], alertData?: unknown): string | null =>
export const getRuleId = (
attachment: BuilderArgs['attachment'],
alertData?: unknown
): string | null =>
getRuleField({
commentRuleField: comment?.rule?.id,
attachmentRuleField: attachment?.rule?.id,
alertData,
signalRuleFieldPath: 'signal.rule.id',
kibanaAlertFieldPath: ALERT_RULE_UUID,
});
export const getRuleName = (comment: BuilderArgs['comment'], alertData?: unknown): string | null =>
export const getRuleName = (
attachment: BuilderArgs['attachment'],
alertData?: unknown
): string | null =>
getRuleField({
commentRuleField: comment?.rule?.name,
attachmentRuleField: attachment?.rule?.name,
alertData,
signalRuleFieldPath: 'signal.rule.name',
kibanaAlertFieldPath: ALERT_RULE_NAME,
});
const getRuleField = ({
commentRuleField,
attachmentRuleField,
alertData,
signalRuleFieldPath,
kibanaAlertFieldPath,
}: {
commentRuleField: string | string[] | null | undefined;
attachmentRuleField: string | string[] | null | undefined;
alertData: unknown | undefined;
signalRuleFieldPath: string;
kibanaAlertFieldPath: string;
}): string | null => {
const field =
getNonEmptyField(commentRuleField) ??
getNonEmptyField(attachmentRuleField) ??
getNonEmptyField(get(alertData, signalRuleFieldPath)) ??
getNonEmptyField(get(alertData, kibanaAlertFieldPath));
@ -218,16 +224,19 @@ function getNonEmptyField(field: string | string[] | undefined | null): string |
return firstItem;
}
export function getRuleInfo(comment: BuilderArgs['comment'], alertData: BuilderArgs['alertData']) {
const alertId = getNonEmptyField(comment.alertId);
export function getRuleInfo(
attachment: BuilderArgs['attachment'],
alertData: BuilderArgs['alertData']
) {
const alertId = getNonEmptyField(attachment.alertId);
if (!alertId) {
return { ruleId: null, ruleName: null };
}
const alertField: unknown | undefined = alertData[alertId];
const ruleId = getRuleId(comment, alertField);
const ruleName = getRuleName(comment, alertField);
const ruleId = getRuleId(attachment, alertField);
const ruleName = getRuleName(attachment, alertField);
return { ruleId, ruleName };
}

View file

@ -408,8 +408,8 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [alertComment],
},
attachments: [alertComment],
userAction,
});
@ -432,8 +432,8 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [alertComment],
},
attachments: [alertComment],
userAction,
});
@ -465,8 +465,8 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [alertComment],
},
attachments: [alertComment],
userAction,
});
@ -501,14 +501,14 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
},
attachments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
userAction,
});
@ -528,14 +528,14 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
},
attachments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
userAction,
});
@ -564,14 +564,14 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
},
attachments: [
{
...alertComment,
alertId: ['alert-id-1', 'alert-id-2'],
index: ['alert-index-1', 'alert-index-2'],
},
],
userAction,
});
@ -595,8 +595,8 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [hostIsolationComment()],
},
attachments: [hostIsolationComment()],
userAction,
});
@ -623,8 +623,8 @@ describe('createCommentUserActionBuilder', () => {
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [hostIsolationComment({ createdBy })],
},
attachments: [hostIsolationComment({ createdBy })],
userAction,
});
@ -663,17 +663,17 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [
{
...externalReferenceAttachment,
createdBy: {
username: damagedRaccoon.user.username,
fullName: damagedRaccoon.user.full_name,
email: damagedRaccoon.user.email,
},
},
],
},
attachments: [
{
...externalReferenceAttachment,
createdBy: {
username: damagedRaccoon.user.username,
fullName: damagedRaccoon.user.full_name,
email: damagedRaccoon.user.email,
},
},
],
userAction,
});
@ -696,8 +696,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -720,8 +720,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -780,8 +780,8 @@ describe('createCommentUserActionBuilder', () => {
persistableStateAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [attachment01],
},
attachments: [attachment01],
userAction,
});
@ -809,8 +809,8 @@ describe('createCommentUserActionBuilder', () => {
persistableStateAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [attachment02],
},
attachments: [attachment02],
userAction,
});
@ -832,8 +832,8 @@ describe('createCommentUserActionBuilder', () => {
persistableStateAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [persistableStateAttachment],
},
attachments: [persistableStateAttachment],
userAction,
});
@ -856,8 +856,8 @@ describe('createCommentUserActionBuilder', () => {
persistableStateAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [persistableStateAttachment],
},
attachments: [persistableStateAttachment],
userAction,
});
@ -915,8 +915,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -962,8 +962,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -1018,8 +1018,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -1073,8 +1073,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -1149,8 +1149,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});
@ -1200,8 +1200,8 @@ describe('createCommentUserActionBuilder', () => {
externalReferenceAttachmentTypeRegistry,
caseData: {
...builderArgs.caseData,
comments: [externalReferenceAttachment],
},
attachments: [externalReferenceAttachment],
userAction,
});

View file

@ -148,7 +148,7 @@ const getCreateCommentUserAction = ({
caseData,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
comment,
attachment,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
@ -166,21 +166,21 @@ const getCreateCommentUserAction = ({
actionsNavigation,
}: {
userAction: SnakeToCamelCase<CommentUserAction>;
comment: AttachmentUI;
attachment: AttachmentUI;
} & Omit<
UserActionBuilderArgs,
'comments' | 'index' | 'handleOutlineComment' | 'currentUserProfile'
>): EuiCommentProps[] => {
switch (comment.type) {
switch (attachment.type) {
case AttachmentType.user:
const userBuilder = createUserAttachmentUserActionBuilder({
appId,
userProfiles,
comment,
outlined: comment.id === selectedOutlineCommentId,
isEdit: manageMarkdownEditIds.includes(comment.id),
attachment,
outlined: attachment.id === selectedOutlineCommentId,
isEdit: manageMarkdownEditIds.includes(attachment.id),
commentRefs,
isLoading: loadingCommentIds.includes(comment.id),
isLoading: loadingCommentIds.includes(attachment.id),
caseId: caseData.id,
euiTheme,
handleManageMarkdownEditId,
@ -195,7 +195,7 @@ const getCreateCommentUserAction = ({
const alertBuilder = createAlertAttachmentUserActionBuilder({
userProfiles,
alertData,
comment,
attachment,
userAction,
getRuleDetailsHref,
loadingAlertData,
@ -211,7 +211,7 @@ const getCreateCommentUserAction = ({
const actionBuilder = createActionAttachmentUserActionBuilder({
userProfiles,
userAction,
comment,
attachment,
actionsNavigation,
});
@ -221,10 +221,10 @@ const getCreateCommentUserAction = ({
const externalReferenceBuilder = createExternalReferenceAttachmentUserActionBuilder({
userAction,
userProfiles,
comment,
attachment,
externalReferenceAttachmentTypeRegistry,
caseData,
isLoading: loadingCommentIds.includes(comment.id),
isLoading: loadingCommentIds.includes(attachment.id),
handleDeleteComment,
});
@ -234,10 +234,10 @@ const getCreateCommentUserAction = ({
const persistableBuilder = createPersistableStateAttachmentUserActionBuilder({
userAction,
userProfiles,
comment,
attachment,
persistableStateAttachmentTypeRegistry,
caseData,
isLoading: loadingCommentIds.includes(comment.id),
isLoading: loadingCommentIds.includes(attachment.id),
handleDeleteComment,
});
@ -273,13 +273,14 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({
handleOutlineComment,
actionsNavigation,
caseConnectors,
attachments,
}) => ({
build: () => {
const commentUserAction = userAction as SnakeToCamelCase<CommentUserAction>;
const attachmentUserAction = userAction as SnakeToCamelCase<CommentUserAction>;
if (commentUserAction.action === UserActionActions.delete) {
if (attachmentUserAction.action === UserActionActions.delete) {
return getDeleteCommentUserAction({
userAction: commentUserAction,
userAction: attachmentUserAction,
caseData,
handleOutlineComment,
userProfiles,
@ -288,22 +289,22 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({
});
}
const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId);
const attachment = attachments.find((c) => c.id === attachmentUserAction.commentId);
if (comment == null) {
if (attachment == null) {
return [];
}
if (commentUserAction.action === UserActionActions.create) {
if (attachmentUserAction.action === UserActionActions.create) {
const commentAction = getCreateCommentUserAction({
appId,
caseData,
casesConfiguration,
userProfiles,
userAction: commentUserAction,
userAction: attachmentUserAction,
externalReferenceAttachmentTypeRegistry,
persistableStateAttachmentTypeRegistry,
comment,
attachment,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
@ -320,6 +321,7 @@ export const createCommentUserActionBuilder: UserActionBuilder = ({
handleManageQuote,
actionsNavigation,
caseConnectors,
attachments,
});
return commentAction;

View file

@ -18,14 +18,14 @@ type BuilderArgs = Pick<
| 'handleDeleteComment'
| 'userProfiles'
> & {
comment: SnakeToCamelCase<ExternalReferenceAttachment>;
attachment: SnakeToCamelCase<ExternalReferenceAttachment>;
isLoading: boolean;
};
export const createExternalReferenceAttachmentUserActionBuilder = ({
userAction,
userProfiles,
comment,
attachment,
externalReferenceAttachmentTypeRegistry,
caseData,
isLoading,
@ -34,15 +34,15 @@ export const createExternalReferenceAttachmentUserActionBuilder = ({
return createRegisteredAttachmentUserActionBuilder({
userAction,
userProfiles,
comment,
attachment,
registry: externalReferenceAttachmentTypeRegistry,
caseData,
handleDeleteComment,
isLoading,
getId: () => comment.externalReferenceAttachmentTypeId,
getId: () => attachment.externalReferenceAttachmentTypeId,
getAttachmentViewProps: () => ({
externalReferenceId: comment.externalReferenceId,
externalReferenceMetadata: comment.externalReferenceMetadata,
externalReferenceId: attachment.externalReferenceId,
externalReferenceMetadata: attachment.externalReferenceMetadata,
}),
});
};

View file

@ -18,14 +18,14 @@ type BuilderArgs = Pick<
| 'handleDeleteComment'
| 'userProfiles'
> & {
comment: SnakeToCamelCase<PersistableStateAttachment>;
attachment: SnakeToCamelCase<PersistableStateAttachment>;
isLoading: boolean;
};
export const createPersistableStateAttachmentUserActionBuilder = ({
userAction,
userProfiles,
comment,
attachment,
persistableStateAttachmentTypeRegistry,
caseData,
isLoading,
@ -34,15 +34,15 @@ export const createPersistableStateAttachmentUserActionBuilder = ({
return createRegisteredAttachmentUserActionBuilder({
userAction,
userProfiles,
comment,
attachment,
registry: persistableStateAttachmentTypeRegistry,
caseData,
handleDeleteComment,
isLoading,
getId: () => comment.persistableStateAttachmentTypeId,
getId: () => attachment.persistableStateAttachmentTypeId,
getAttachmentViewProps: () => ({
persistableStateAttachmentTypeId: comment.persistableStateAttachmentTypeId,
persistableStateAttachmentState: comment.persistableStateAttachmentState,
persistableStateAttachmentTypeId: attachment.persistableStateAttachmentTypeId,
persistableStateAttachmentState: attachment.persistableStateAttachmentState,
}),
});
};

View file

@ -58,7 +58,7 @@ describe('createRegisteredAttachmentUserActionBuilder', () => {
registry.register(item);
const comment = builderArgs.comments[0];
const attachment = builderArgs.attachments[0];
const userActionBuilderArgs = {
userAction: builderArgs.userAction,
@ -68,7 +68,7 @@ describe('createRegisteredAttachmentUserActionBuilder', () => {
getId,
getAttachmentViewProps,
isLoading: false,
comment,
attachment,
registry,
};
@ -97,7 +97,7 @@ describe('createRegisteredAttachmentUserActionBuilder', () => {
expect(getAttachmentViewProps).toHaveBeenCalled();
expect(getAttachmentViewObject).toBeCalledWith({
...viewProps,
attachmentId: comment.id,
attachmentId: attachment.id,
caseData: { id: builderArgs.caseData.id, title: builderArgs.caseData.title },
});
});

View file

@ -41,7 +41,7 @@ type BuilderArgs<C, R> = Pick<
UserActionBuilderArgs,
'userAction' | 'caseData' | 'handleDeleteComment' | 'userProfiles'
> & {
comment: SnakeToCamelCase<C>;
attachment: SnakeToCamelCase<C>;
registry: R;
isLoading: boolean;
getId: () => string;
@ -82,7 +82,7 @@ export const createRegisteredAttachmentUserActionBuilder = <
>({
userAction,
userProfiles,
comment,
attachment,
registry,
caseData,
isLoading,
@ -98,7 +98,10 @@ export const createRegisteredAttachmentUserActionBuilder = <
return [
{
username: (
<HoverableUserWithAvatarResolver user={comment.createdBy} userProfiles={userProfiles} />
<HoverableUserWithAvatarResolver
user={attachment.createdBy}
userProfiles={userProfiles}
/>
),
event: (
<>
@ -106,8 +109,8 @@ export const createRegisteredAttachmentUserActionBuilder = <
<EuiCode>{attachmentTypeId}</EuiCode>
</>
),
className: `comment-${comment.type}-not-found`,
'data-test-subj': `comment-${comment.type}-not-found`,
className: `comment-${attachment.type}-not-found`,
'data-test-subj': `comment-${attachment.type}-not-found`,
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
children: (
<EuiCallOut title={ATTACHMENT_NOT_REGISTERED_ERROR} color="danger" iconType="warning" />
@ -120,7 +123,7 @@ export const createRegisteredAttachmentUserActionBuilder = <
const props = {
...getAttachmentViewProps(),
attachmentId: comment.id,
attachmentId: attachment.id,
caseData: { id: caseData.id, title: caseData.title },
};
@ -135,30 +138,33 @@ export const createRegisteredAttachmentUserActionBuilder = <
return [
{
username: (
<HoverableUserWithAvatarResolver user={comment.createdBy} userProfiles={userProfiles} />
<HoverableUserWithAvatarResolver
user={attachment.createdBy}
userProfiles={userProfiles}
/>
),
className: `comment-${comment.type}-attachment-${attachmentTypeId}`,
className: `comment-${attachment.type}-attachment-${attachmentTypeId}`,
event: attachmentViewObject.event,
'data-test-subj': `comment-${comment.type}-${attachmentTypeId}`,
'data-test-subj': `comment-${attachment.type}-${attachmentTypeId}`,
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineAvatar: attachmentViewObject.timelineAvatar,
actions: (
<UserActionContentToolbar id={comment.id}>
<UserActionContentToolbar id={attachment.id}>
{visiblePrimaryActions.map(
(action) =>
(action.type === AttachmentActionType.BUTTON && (
<EuiFlexItem
grow={false}
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}`}
key={`attachment-${attachmentTypeId}-${comment.id}`}
data-test-subj={`attachment-${attachmentTypeId}-${attachment.id}`}
key={`attachment-${attachmentTypeId}-${attachment.id}`}
>
<EuiButtonIcon
aria-label={action.label}
iconType={action.iconType}
color={action.color ?? 'text'}
onClick={action.onClick}
data-test-subj={`attachment-${attachmentTypeId}-${comment.id}-${action.iconType}`}
key={`attachment-${attachmentTypeId}-${comment.id}-${action.iconType}`}
data-test-subj={`attachment-${attachmentTypeId}-${attachment.id}-${action.iconType}`}
key={`attachment-${attachmentTypeId}-${attachment.id}-${action.iconType}`}
/>
</EuiFlexItem>
)) ||
@ -166,7 +172,7 @@ export const createRegisteredAttachmentUserActionBuilder = <
)}
<RegisteredAttachmentsPropertyActions
isLoading={isLoading}
onDelete={() => handleDeleteComment(comment.id, DELETE_REGISTERED_ATTACHMENT)}
onDelete={() => handleDeleteComment(attachment.id, DELETE_REGISTERED_ATTACHMENT)}
registeredAttachmentActions={[...nonVisiblePrimaryActions, ...nonPrimaryActions]}
hideDefaultActions={!!attachmentViewObject.hideDefaultActions}
/>

View file

@ -34,7 +34,7 @@ type BuilderArgs = Pick<
| 'appId'
| 'euiTheme'
> & {
comment: SnakeToCamelCase<UserCommentAttachment>;
attachment: SnakeToCamelCase<UserCommentAttachment>;
caseId: string;
outlined: boolean;
isEdit: boolean;
@ -66,7 +66,7 @@ const hasDraftComment = (
export const createUserAttachmentUserActionBuilder = ({
appId,
comment,
attachment,
userProfiles,
outlined,
isEdit,
@ -81,33 +81,39 @@ export const createUserAttachmentUserActionBuilder = ({
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => [
{
username: <HoverableUsernameResolver user={comment.createdBy} userProfiles={userProfiles} />,
'data-test-subj': `comment-create-action-${comment.id}`,
username: (
<HoverableUsernameResolver user={attachment.createdBy} userProfiles={userProfiles} />
),
'data-test-subj': `comment-create-action-${attachment.id}`,
timestamp: (
<UserActionTimestamp createdAt={comment.createdAt} updatedAt={comment.updatedAt} />
<UserActionTimestamp createdAt={attachment.createdAt} updatedAt={attachment.updatedAt} />
),
className: classNames('userAction__comment', {
outlined,
isEdit,
draftFooter:
!isEdit && !isLoading && hasDraftComment(appId, caseId, comment.id, comment.comment),
!isEdit &&
!isLoading &&
hasDraftComment(appId, caseId, attachment.id, attachment.comment),
}),
children: (
<>
<UserActionMarkdown
key={isEdit ? comment.id : undefined}
ref={(element) => (commentRefs.current[comment.id] = element)}
id={comment.id}
content={comment.comment}
key={isEdit ? attachment.id : undefined}
ref={(element) => (commentRefs.current[attachment.id] = element)}
id={attachment.id}
content={attachment.comment}
isEditable={isEdit}
caseId={caseId}
onChangeEditable={handleManageMarkdownEditId}
onSaveContent={handleSaveComment.bind(null, {
id: comment.id,
version: comment.version,
id: attachment.id,
version: attachment.version,
})}
/>
{!isEdit && !isLoading && hasDraftComment(appId, caseId, comment.id, comment.comment) ? (
{!isEdit &&
!isLoading &&
hasDraftComment(appId, caseId, attachment.id, attachment.comment) ? (
<EuiText css={getCommentFooterCss(euiTheme)}>
<EuiText color="subdued" size="xs" data-test-subj="user-action-comment-unsaved-draft">
{i18n.UNSAVED_DRAFT_COMMENT}
@ -119,16 +125,16 @@ export const createUserAttachmentUserActionBuilder = ({
</>
),
timelineAvatar: (
<HoverableAvatarResolver user={comment.createdBy} userProfiles={userProfiles} />
<HoverableAvatarResolver user={attachment.createdBy} userProfiles={userProfiles} />
),
actions: (
<UserActionContentToolbar id={comment.id}>
<UserActionContentToolbar id={attachment.id}>
<UserCommentPropertyActions
isLoading={isLoading}
commentContent={comment.comment}
onEdit={() => handleManageMarkdownEditId(comment.id)}
onDelete={() => handleDeleteComment(comment.id, i18n.DELETE_COMMENT_SUCCESS_TITLE)}
onQuote={() => handleManageQuote(comment.comment)}
commentContent={attachment.comment}
onEdit={() => handleManageMarkdownEditId(attachment.id)}
onDelete={() => handleDeleteComment(attachment.id, i18n.DELETE_COMMENT_SUCCESS_TITLE)}
onQuote={() => handleManageQuote(attachment.comment)}
/>
</UserActionContentToolbar>
),

View file

@ -82,6 +82,7 @@ export const UserActions = React.memo((props: UserActionTreeProps) => {
const {
infiniteCaseUserActions,
infiniteLatestAttachments,
isLoadingInfiniteUserActions,
hasNextPage,
fetchNextPage,
@ -95,11 +96,12 @@ export const UserActions = React.memo((props: UserActionTreeProps) => {
const { euiTheme } = useEuiTheme();
const { isLoadingLastPageUserActions, lastPageUserActions } = useLastPageUserActions({
userActivityQueryParams,
caseId: caseData.id,
lastPage,
});
const { isLoadingLastPageUserActions, lastPageUserActions, lastPageAttachments } =
useLastPageUserActions({
userActivityQueryParams,
caseId: caseData.id,
lastPage,
});
const alertIdsWithoutRuleInfo = useMemo(
() => getManualAlertIdsWithNoRuleId(caseData.comments),
@ -180,6 +182,7 @@ export const UserActions = React.memo((props: UserActionTreeProps) => {
<UserActionsList
{...props}
caseUserActions={infiniteCaseUserActions}
attachments={infiniteLatestAttachments}
loadingAlertData={loadingAlertData}
manualAlertsData={manualAlertsData}
commentRefs={commentRefs}
@ -203,6 +206,7 @@ export const UserActions = React.memo((props: UserActionTreeProps) => {
<UserActionsList
{...props}
caseUserActions={lastPageUserActions}
attachments={lastPageAttachments}
loadingAlertData={loadingAlertData}
manualAlertsData={manualAlertsData}
bottomActions={bottomActions}

View file

@ -65,7 +65,7 @@ export const getMockBuilderArgs = (): UserActionBuilderArgs => {
persistableStateAttachmentTypeRegistry,
caseData: basicCase,
casesConfiguration: casesConfigurationsMock,
comments: basicCase.comments,
attachments: basicCase.comments,
index: 0,
alertData,
commentRefs,

View file

@ -60,7 +60,7 @@ export interface UserActionBuilderArgs {
persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry;
caseConnectors: CaseConnectors;
userAction: UserActionUI;
comments: AttachmentUI[];
attachments: AttachmentUI[];
index: number;
commentRefs: React.MutableRefObject<
Record<string, AddCommentRefObject | UserActionMarkdownRefObject | null | undefined>

View file

@ -7,7 +7,7 @@
import { useMemo } from 'react';
import type { UserActionUI } from '../../containers/types';
import type { AttachmentUI, UserActionUI } from '../../containers/types';
import { useFindCaseUserActions } from '../../containers/use_find_case_user_actions';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
@ -25,16 +25,23 @@ export const useLastPageUserActions = ({
const { data: lastPageUserActionsData, isLoading: isLoadingLastPageUserActions } =
useFindCaseUserActions(caseId, { ...userActivityQueryParams, page: lastPage }, lastPage > 1);
const lastPageUserActions = useMemo<UserActionUI[]>(() => {
const { userActions, latestAttachments } = useMemo<{
userActions: UserActionUI[];
latestAttachments: AttachmentUI[];
}>(() => {
if (isLoadingLastPageUserActions || !lastPageUserActionsData) {
return [];
return { userActions: [], latestAttachments: [] };
}
return lastPageUserActionsData.userActions;
return {
userActions: lastPageUserActionsData.userActions,
latestAttachments: lastPageUserActionsData.latestAttachments,
};
}, [lastPageUserActionsData, isLoadingLastPageUserActions]);
return {
isLoadingLastPageUserActions,
lastPageUserActions,
lastPageUserActions: userActions,
lastPageAttachments: latestAttachments,
};
};

View file

@ -8,7 +8,7 @@
import { useMemo } from 'react';
import { useInfiniteFindCaseUserActions } from '../../containers/use_infinite_find_case_user_actions';
import type { UserActionUI } from '../../containers/types';
import type { AttachmentUI, UserActionUI } from '../../containers/types';
import type { UserActivityParams } from '../user_actions_activity_bar/types';
interface UserActionsPagination {
@ -32,23 +32,32 @@ export const useUserActionsPagination = ({
const showBottomList = lastPage > 1;
const infiniteCaseUserActions = useMemo<UserActionUI[]>(() => {
const infiniteCaseUserActions = useMemo<{
userActions: UserActionUI[];
latestAttachments: AttachmentUI[];
}>(() => {
if (!caseInfiniteUserActionsData?.pages?.length || isLoadingInfiniteUserActions) {
return [];
return { userActions: [], latestAttachments: [] };
}
const userActionsData: UserActionUI[] = [];
const latestAttachments: AttachmentUI[] = [];
// TODO: looks like it can be done in one loop
caseInfiniteUserActionsData.pages.forEach((page) => userActionsData.push(...page.userActions));
caseInfiniteUserActionsData.pages.forEach((page) =>
latestAttachments.push(...page.latestAttachments)
);
return userActionsData;
return { userActions: userActionsData, latestAttachments };
}, [caseInfiniteUserActionsData, isLoadingInfiniteUserActions]);
return {
lastPage,
showBottomList,
isLoadingInfiniteUserActions,
infiniteCaseUserActions,
infiniteCaseUserActions: infiniteCaseUserActions.userActions,
infiniteLatestAttachments: infiniteCaseUserActions.latestAttachments,
hasNextPage,
fetchNextPage,
isFetchingNextPage,

View file

@ -11,7 +11,7 @@ import { EuiCommentList, useEuiTheme } from '@elastic/eui';
import React, { useMemo, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import type { UserActionUI } from '../../containers/types';
import type { AttachmentUI, UserActionUI } from '../../containers/types';
import type { UserActionBuilderArgs, UserActionTreeProps } from './types';
import { isUserActionTypeSupported } from './helpers';
import { useCasesContext } from '../cases_context/use_cases_context';
@ -66,6 +66,7 @@ export type UserActionListProps = Omit<
> &
Pick<UserActionBuilderArgs, 'commentRefs' | 'handleManageQuote'> & {
caseUserActions: UserActionUI[];
attachments: AttachmentUI[];
loadingAlertData: boolean;
manualAlertsData: Record<string, unknown>;
bottomActions?: EuiCommentProps[];
@ -75,6 +76,7 @@ export type UserActionListProps = Omit<
export const UserActionsList = React.memo(
({
caseUserActions,
attachments,
caseConnectors,
userProfiles,
currentUserProfile,
@ -113,15 +115,15 @@ export const UserActionsList = React.memo(
return [];
}
return caseUserActions.reduce<EuiCommentProps[]>((comments, userAction, index) => {
return caseUserActions.reduce<EuiCommentProps[]>((userActions, userAction, index) => {
if (!isUserActionTypeSupported(userAction.type)) {
return comments;
return userActions;
}
const builder = builderMap[userAction.type];
if (builder == null) {
return comments;
return userActions;
}
const userActionBuilder = builder({
@ -134,7 +136,7 @@ export const UserActionsList = React.memo(
userAction,
userProfiles,
currentUserProfile,
comments: caseData?.comments,
attachments,
index,
commentRefs,
manageMarkdownEditIds,
@ -153,7 +155,7 @@ export const UserActionsList = React.memo(
getRuleDetailsHref,
onRuleDetailsClick,
});
return [...comments, ...userActionBuilder.build()];
return [...userActions, ...userActionBuilder.build()];
}, []);
}, [
caseUserActions,
@ -165,6 +167,7 @@ export const UserActionsList = React.memo(
persistableStateAttachmentTypeRegistry,
userProfiles,
currentUserProfile,
attachments,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,

View file

@ -51,12 +51,6 @@ import type { UserProfile } from '@kbn/security-plugin/common';
import { userProfiles } from '../user_profiles/api.mock';
import { getCaseConnectorsMockResponse } from '../../common/mock/connectors';
export const getCase = async (
caseId: string,
includeComments: boolean = true,
signal: AbortSignal
): Promise<CaseUI> => Promise.resolve(basicCase);
export const resolveCase = async (
caseId: string,
includeComments: boolean = true,

View file

@ -22,7 +22,6 @@ import {
deleteCases,
deleteComment,
getActionLicense,
getCase,
getCases,
findCaseUserActions,
getTags,
@ -135,34 +134,6 @@ describe('Cases API', () => {
});
});
describe('getCase', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue(basicCaseSnake);
});
const data = basicCase.id;
it('should be called with correct check url, method, signal', async () => {
await getCase(data, true, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}`, {
method: 'GET',
query: { includeComments: true },
signal: abortCtrl.signal,
});
});
it('should return correct response', async () => {
const resp = await getCase(data, true, abortCtrl.signal);
expect(resp).toEqual(basicCase);
});
it('should not covert to camel case registered attachments', async () => {
fetchMock.mockResolvedValue(caseWithRegisteredAttachmentsSnake);
const resp = await getCase(data, true, abortCtrl.signal);
expect(resp).toEqual(caseWithRegisteredAttachments);
});
});
describe('resolveCase', () => {
const aliasTargetId = '12345';
const basicResolveCase = {
@ -180,7 +151,9 @@ describe('Cases API', () => {
await resolveCase({ caseId, signal: abortCtrl.signal });
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${caseId}/resolve`, {
method: 'GET',
query: { includeComments: true },
query: {
includeComments: false,
},
signal: abortCtrl.signal,
});
});
@ -540,6 +513,7 @@ describe('Cases API', () => {
perPage: 10,
total: 30,
userActions: [...caseUserActionsWithRegisteredAttachmentsSnake],
latestAttachments: [],
};
const filterActionType: CaseUserActionTypeWithAll = 'all';
const sortOrder: 'asc' | 'desc' = 'asc';
@ -557,16 +531,19 @@ describe('Cases API', () => {
it('should be called with correct check url, method, signal', async () => {
await findCaseUserActions(basicCase.id, params, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions/_find`, {
method: 'GET',
signal: abortCtrl.signal,
query: {
types: [],
sortOrder: 'asc',
page: 1,
perPage: 10,
},
});
expect(fetchMock).toHaveBeenCalledWith(
`${CASES_INTERNAL_URL}/${basicCase.id}/user_actions/_find`,
{
method: 'GET',
signal: abortCtrl.signal,
query: {
types: [],
sortOrder: 'asc',
page: 1,
perPage: 10,
},
}
);
});
it('should be called with action type user action and desc sort order', async () => {
@ -575,30 +552,36 @@ describe('Cases API', () => {
{ type: 'action', sortOrder: 'desc', page: 2, perPage: 15 },
abortCtrl.signal
);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions/_find`, {
method: 'GET',
signal: abortCtrl.signal,
query: {
types: ['action'],
sortOrder: 'desc',
page: 2,
perPage: 15,
},
});
expect(fetchMock).toHaveBeenCalledWith(
`${CASES_INTERNAL_URL}/${basicCase.id}/user_actions/_find`,
{
method: 'GET',
signal: abortCtrl.signal,
query: {
types: ['action'],
sortOrder: 'desc',
page: 2,
perPage: 15,
},
}
);
});
it('should be called with user type user action and desc sort order', async () => {
await findCaseUserActions(basicCase.id, { ...params, type: 'user' }, abortCtrl.signal);
expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/user_actions/_find`, {
method: 'GET',
signal: abortCtrl.signal,
query: {
types: ['user'],
sortOrder: 'asc',
page: 1,
perPage: 10,
},
});
expect(fetchMock).toHaveBeenCalledWith(
`${CASES_INTERNAL_URL}/${basicCase.id}/user_actions/_find`,
{
method: 'GET',
signal: abortCtrl.signal,
query: {
types: ['user'],
sortOrder: 'asc',
page: 1,
perPage: 10,
},
}
);
});
it('should return correct response', async () => {

View file

@ -19,19 +19,18 @@ import type {
CasesFindResponse,
CaseUserActionStatsResponse,
GetCaseConnectorsResponse,
UserActionFindResponse,
SingleCaseMetricsResponse,
CustomFieldPutRequest,
CasesSimilarResponse,
AddObservableRequest,
UpdateObservableRequest,
UserActionInternalFindResponse,
} from '../../common/types/api';
import type {
CaseConnectors,
CaseUpdateRequest,
FetchCasesProps,
ResolvedCase,
FindCaseUserActions,
CaseUserActionTypeWithAll,
CaseUserActionsStats,
CaseUsers,
@ -41,6 +40,7 @@ import type {
CaseUICustomField,
SimilarCasesProps,
CasesSimilarResponseUI,
InternalFindCaseUserActions,
} from '../../common/ui/types';
import { SortFieldCase } from '../../common/ui/types';
import {
@ -81,6 +81,7 @@ import {
convertCasesToCamelCase,
convertCaseResolveToCamelCase,
convertSimilarCasesToCamel,
convertAttachmentsToCamelCase,
} from '../api/utils';
import type {
@ -105,37 +106,18 @@ import {
} from './utils';
import { decodeCasesFindResponse, decodeCasesSimilarResponse } from '../api/decoders';
export const getCase = async (
caseId: string,
includeComments: boolean = true,
signal: AbortSignal
): Promise<CaseUI> => {
const response = await KibanaServices.get().http.fetch<Case>(getCaseDetailsUrl(caseId), {
method: 'GET',
query: {
includeComments,
},
signal,
});
return convertCaseToCamelCase(decodeCaseResponse(response));
};
export const resolveCase = async ({
caseId,
includeComments = true,
signal,
}: {
caseId: string;
includeComments?: boolean;
signal?: AbortSignal;
}): Promise<ResolvedCase> => {
const response = await KibanaServices.get().http.fetch<CaseResolveResponse>(
`${getCaseDetailsUrl(caseId)}/resolve`,
{
method: 'GET',
query: {
includeComments,
},
query: { includeComments: false },
signal,
}
);
@ -211,7 +193,7 @@ export const findCaseUserActions = async (
perPage: number;
},
signal?: AbortSignal
): Promise<FindCaseUserActions> => {
): Promise<InternalFindCaseUserActions> => {
const query = {
types: params.type !== 'all' ? [params.type] : [],
sortOrder: params.sortOrder,
@ -219,7 +201,7 @@ export const findCaseUserActions = async (
perPage: params.perPage,
};
const response = await KibanaServices.get().http.fetch<UserActionFindResponse>(
const response = await KibanaServices.get().http.fetch<UserActionInternalFindResponse>(
getCaseFindUserActionsUrl(caseId),
{
method: 'GET',
@ -233,6 +215,7 @@ export const findCaseUserActions = async (
userActions: convertUserActionsToCamelCase(
decodeCaseUserActionsResponse(response.userActions)
) as UserActionUI[],
latestAttachments: convertAttachmentsToCamelCase(response.latestAttachments),
};
};

View file

@ -38,7 +38,6 @@ import type {
CasesMetrics,
ExternalReferenceAttachmentUI,
PersistableStateAttachmentUI,
FindCaseUserActions,
CaseUsers,
CaseUserActionsStats,
CasesFindResponseUI,
@ -49,6 +48,7 @@ import type {
CasesConfigurationUITemplate,
CasesSimilarResponseUI,
ObservableUI,
InternalFindCaseUserActions,
} from '../../common/ui/types';
import { CaseMetricsFeature } from '../../common/types/api';
import { OBSERVABLE_TYPE_IPV4, SECURITY_SOLUTION_OWNER } from '../../common/constants';
@ -974,11 +974,12 @@ export const caseUserActionsWithRegisteredAttachments: UserActionUI[] = [
},
];
export const findCaseUserActionsResponse: FindCaseUserActions = {
export const findCaseUserActionsResponse: InternalFindCaseUserActions = {
page: 1,
perPage: 10,
total: 30,
userActions: [...caseUserActionsWithRegisteredAttachments],
latestAttachments: [],
};
export const getCaseUserActionsStatsResponse: CaseUserActionsStats = {

View file

@ -52,6 +52,7 @@ describe('UseFindCaseUserActions', () => {
expect.objectContaining({
...initialData,
data: {
latestAttachments: [],
userActions: [...findCaseUserActionsResponse.userActions],
total: 30,
perPage: 10,

View file

@ -6,7 +6,7 @@
*/
import { useQuery } from '@tanstack/react-query';
import type { FindCaseUserActions, CaseUserActionTypeWithAll } from '../../common/ui/types';
import type { CaseUserActionTypeWithAll, InternalFindCaseUserActions } from '../../common/ui/types';
import { findCaseUserActions } from './api';
import type { ServerError } from '../types';
import { useCasesToast } from '../common/use_cases_toast';
@ -25,7 +25,7 @@ export const useFindCaseUserActions = (
) => {
const { showErrorToast } = useCasesToast();
return useQuery<FindCaseUserActions, ServerError>(
return useQuery<InternalFindCaseUserActions, ServerError>(
casesQueriesKeys.caseUserActions(caseId, params),
async ({ signal }) => findCaseUserActions(caseId, params, signal),
{

View file

@ -17,7 +17,7 @@ export const useGetCase = (caseId: string) => {
const toasts = useToasts();
return useQuery<ResolvedCase, ServerError>(
casesQueriesKeys.case(caseId),
({ signal }) => resolveCase({ caseId, includeComments: true, signal }),
({ signal }) => resolveCase({ caseId, signal }),
{
onError: (error: ServerError) => {
if (error.name !== 'AbortError') {

View file

@ -55,6 +55,7 @@ describe('UseInfiniteFindCaseUserActions', () => {
data: {
pages: [
{
latestAttachments: [],
userActions: [...findCaseUserActionsResponse.userActions],
total: 30,
perPage: 10,

View file

@ -6,7 +6,7 @@
*/
import { useInfiniteQuery } from '@tanstack/react-query';
import type { FindCaseUserActions, CaseUserActionTypeWithAll } from '../../common/ui/types';
import type { InternalFindCaseUserActions, CaseUserActionTypeWithAll } from '../../common/ui/types';
import { findCaseUserActions } from './api';
import type { ServerError } from '../types';
import { useCasesToast } from '../common/use_cases_toast';
@ -25,7 +25,7 @@ export const useInfiniteFindCaseUserActions = (
const { showErrorToast } = useCasesToast();
const abortCtrlRef = new AbortController();
return useInfiniteQuery<FindCaseUserActions, ServerError>(
return useInfiniteQuery<InternalFindCaseUserActions, ServerError>(
casesQueriesKeys.caseUserActions(caseId, params),
async ({ pageParam = 1 }) => {
return findCaseUserActions(caseId, { ...params, page: pageParam }, abortCtrlRef.signal);

View file

@ -24,6 +24,7 @@ import { postObservableRoute } from './observables/post_observable';
import { similarCaseRoute } from './cases/similar';
import { patchObservableRoute } from './observables/patch_observable';
import { deleteObservableRoute } from './observables/delete_observable';
import { findUserActionsRoute } from './internal/find_user_actions';
export const getInternalRoutes = (userProfileService: UserProfileService) =>
[
@ -44,4 +45,5 @@ export const getInternalRoutes = (userProfileService: UserProfileService) =>
patchObservableRoute,
deleteObservableRoute,
similarCaseRoute,
findUserActionsRoute,
] as CaseRoute[];

View file

@ -0,0 +1,321 @@
/*
* 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 { findUserActionsRoute } from './find_user_actions';
const userActionsMockData = {
userActions: [
{
type: 'create_case',
payload: {
connector: { id: 'none', type: '.none', fields: null, name: 'none' },
title: 'My Case',
tags: [],
description: 'my case desc.',
settings: { syncAlerts: false },
owner: 'cases',
severity: 'low',
assignees: [],
status: 'open',
category: null,
customFields: [],
},
created_at: '2025-01-07T13:31:55.427Z',
created_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
owner: 'cases',
action: 'create',
comment_id: null,
id: 'e11e39f5-ea29-4cbc-981b-1508cafdb0ad',
version: 'WzIsMV0=',
},
{
payload: { comment: { comment: 'First comment', type: 'user', owner: 'cases' } },
type: 'comment',
created_at: '2025-01-07T13:32:01.314Z',
created_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
owner: 'cases',
action: 'create',
comment_id: '601a03cf-71a0-4949-9407-97cf372b313b',
id: '71f67236-f2f5-4cfe-964d-a4103a9717f2',
version: 'WzUsMV0=',
},
{
payload: { comment: { comment: 'Second comment', type: 'user', owner: 'cases' } },
type: 'comment',
created_at: '2025-01-07T13:32:08.045Z',
created_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
owner: 'cases',
action: 'create',
comment_id: '2cd1eb7d-ff8a-4c0e-b904-0beb64ab166a',
id: '00414cd9-b51a-4b85-a7d3-cb39de4d61db',
version: 'WzgsMV0=',
},
{
payload: { comment: { comment: 'Edited first comment', type: 'user', owner: 'cases' } },
type: 'comment',
created_at: '2025-01-07T13:32:18.160Z',
created_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
owner: 'cases',
action: 'update',
comment_id: '123e4567-e89b-12d3-a456-426614174000',
id: '675cc9a3-5445-4aaa-ad65-21241f095546',
version: 'WzExLDFd',
},
],
page: 1,
perPage: 10,
total: 4,
};
const attachmentsMockData = {
attachments: [
{
comment: 'Edited first comment',
type: 'user',
owner: 'cases',
created_at: '2025-01-07T13:32:01.283Z',
created_by: {
email: null,
full_name: null,
username: 'elastic',
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
pushed_at: null,
pushed_by: null,
updated_at: '2025-01-07T13:32:18.127Z',
updated_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
id: '601a03cf-71a0-4949-9407-97cf372b313b',
version: 'WzksMV0=',
},
{
comment: 'Second comment',
type: 'user',
owner: 'cases',
created_at: '2025-01-07T13:32:08.015Z',
created_by: {
email: null,
full_name: null,
username: 'elastic',
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
pushed_at: null,
pushed_by: null,
updated_at: null,
updated_by: null,
id: '2cd1eb7d-ff8a-4c0e-b904-0beb64ab166a',
version: 'WzYsMV0=',
},
{
comment: 'Edited first comment',
type: 'user',
owner: 'cases',
created_at: '2025-01-07T13:32:01.283Z',
created_by: {
email: null,
full_name: null,
username: 'elastic',
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
pushed_at: null,
pushed_by: null,
updated_at: '2025-01-07T13:32:18.127Z',
updated_by: {
username: 'elastic',
full_name: null,
email: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
},
id: '123e4567-e89b-12d3-a456-426614174000',
version: 'WzksMV0=',
},
],
errors: [],
};
describe('findUserActionsRoute', () => {
const response = { ok: jest.fn() };
beforeEach(() => {
jest.clearAllMocks();
});
it('should return user actions and latest attachments', async () => {
const casesClientMock = {
userActions: {
find: jest.fn().mockResolvedValue(userActionsMockData),
},
attachments: {
bulkGet: jest.fn().mockResolvedValue(attachmentsMockData),
},
};
const context = { cases: { getCasesClient: jest.fn().mockResolvedValue(casesClientMock) } };
const request = {
params: {
case_id: 'my_fake_case_id',
},
query: '',
};
// @ts-expect-error: mocking necessary properties for handler logic only, no Kibana platform
await findUserActionsRoute.handler({ context, request, response });
expect(casesClientMock.attachments.bulkGet).toHaveBeenCalledWith({
attachmentIDs: [
userActionsMockData.userActions[1].comment_id,
userActionsMockData.userActions[2].comment_id,
userActionsMockData.userActions[3].comment_id,
],
caseID: 'my_fake_case_id',
});
expect(response.ok).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
latestAttachments: expect.arrayContaining([
expect.objectContaining({
comment: 'Edited first comment',
created_at: '2025-01-07T13:32:01.283Z',
created_by: {
email: null,
full_name: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
username: 'elastic',
},
id: '601a03cf-71a0-4949-9407-97cf372b313b',
owner: 'cases',
pushed_at: null,
pushed_by: null,
type: 'user',
updated_at: '2025-01-07T13:32:18.127Z',
updated_by: {
email: null,
full_name: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
username: 'elastic',
},
version: 'WzksMV0=',
}),
]),
}),
})
);
});
it('should return empty attachments when no commentId', async () => {
const casesClientMock = {
userActions: {
// userActionsMockData.userActions[0] must have no commentId
find: jest.fn().mockResolvedValue({ userActions: [userActionsMockData.userActions[0]] }),
},
attachments: {
bulkGet: jest.fn().mockResolvedValue(attachmentsMockData),
},
};
const context = { cases: { getCasesClient: jest.fn().mockResolvedValue(casesClientMock) } };
const request = {
params: {
case_id: 'my_fake_case_id',
},
query: '',
};
// @ts-expect-error: Kibana platform types are mocked for testing, only implementing necessary properties for handler logic
await findUserActionsRoute.handler({ context, request, response });
expect(response.ok).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
latestAttachments: [],
}),
})
);
});
it('should filter repeated comment_ids', async () => {
userActionsMockData.userActions[1].comment_id = userActionsMockData.userActions[2].comment_id;
const casesClientMock = {
userActions: {
find: jest.fn().mockResolvedValue(userActionsMockData),
},
attachments: {
bulkGet: jest.fn().mockResolvedValue(attachmentsMockData),
},
};
const context = { cases: { getCasesClient: jest.fn().mockResolvedValue(casesClientMock) } };
const request = {
params: {
case_id: 'my_fake_case_id',
},
query: '',
};
// @ts-expect-error: mocking necessary properties for handler logic only, no Kibana platform
await findUserActionsRoute.handler({ context, request, response });
expect(casesClientMock.attachments.bulkGet).toHaveBeenCalledWith({
attachmentIDs: [
userActionsMockData.userActions[1].comment_id,
userActionsMockData.userActions[3].comment_id,
],
caseID: 'my_fake_case_id',
});
expect(response.ok).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
latestAttachments: expect.arrayContaining([
expect.objectContaining({
comment: 'Edited first comment',
created_at: '2025-01-07T13:32:01.283Z',
created_by: {
email: null,
full_name: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
username: 'elastic',
},
id: '601a03cf-71a0-4949-9407-97cf372b313b',
owner: 'cases',
pushed_at: null,
pushed_by: null,
type: 'user',
updated_at: '2025-01-07T13:32:18.127Z',
updated_by: {
email: null,
full_name: null,
profile_uid: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0',
username: 'elastic',
},
version: 'WzksMV0=',
}),
]),
}),
})
);
});
});

View file

@ -0,0 +1,79 @@
/*
* 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 { schema } from '@kbn/config-schema';
import { isCommentUserAction } from '../../../../common/utils/user_actions';
import type { attachmentApiV1, userActionApiV1 } from '../../../../common/types/api';
import { INTERNAL_CASE_FIND_USER_ACTIONS_URL } from '../../../../common/constants';
import { createCaseError } from '../../../common/error';
import { createCasesRoute } from '../create_cases_route';
const params = {
params: schema.object({
case_id: schema.string(),
}),
};
export const findUserActionsRoute = createCasesRoute({
method: 'get',
path: INTERNAL_CASE_FIND_USER_ACTIONS_URL,
params,
routerOptions: {
access: 'public',
summary: 'Get user actions by case',
tags: ['oas-tag:cases'],
},
handler: async ({ context, request, response }) => {
try {
const caseContext = await context.cases;
const casesClient = await caseContext.getCasesClient();
const caseId = request.params.case_id;
const options = request.query as userActionApiV1.UserActionFindRequest;
const userActionsResponse: userActionApiV1.UserActionFindResponse =
await casesClient.userActions.find({
caseId,
params: options,
});
const uniqueCommentIds: Set<string> = new Set();
for (const action of userActionsResponse.userActions) {
if (isCommentUserAction(action) && action.comment_id) {
uniqueCommentIds.add(action.comment_id);
}
}
const commentIds = Array.from(uniqueCommentIds);
let attachmentRes: attachmentApiV1.BulkGetAttachmentsResponse = {
attachments: [],
errors: [],
};
if (commentIds.length > 0) {
attachmentRes = await casesClient.attachments.bulkGet({
caseID: caseId,
attachmentIDs: commentIds,
});
}
const res: userActionApiV1.UserActionInternalFindResponse = {
...userActionsResponse,
latestAttachments: attachmentRes.attachments,
};
return response.ok({
body: res,
});
} catch (error) {
throw createCaseError({
message: `Failed to retrieve case details in route case id: ${request.params.case_id}: \n${error}`,
error,
});
}
},
});

View file

@ -53,11 +53,14 @@ import {
GetRelatedCasesByAlertResponse,
SimilarCasesSearchRequest,
CasesSimilarResponse,
UserActionFindRequest,
UserActionInternalFindResponse,
} from '@kbn/cases-plugin/common/types/api';
import {
getCaseCreateObservableUrl,
getCaseUpdateObservableUrl,
getCaseDeleteObservableUrl,
getCaseFindUserActionsUrl,
} from '@kbn/cases-plugin/common/api';
import { User } from '../authentication/types';
import { superUser } from '../authentication/users';
@ -975,3 +978,25 @@ export const similarCases = async ({
return res;
};
export const findInternalCaseUserActions = async ({
supertest,
caseID,
options = {},
expectedHttpCode = 200,
auth = { user: superUser, space: null },
}: {
supertest: SuperTest.Agent;
caseID: string;
options?: UserActionFindRequest;
expectedHttpCode?: number;
auth?: { user: User; space: string | null };
}): Promise<UserActionInternalFindResponse> => {
const { body: userActions } = await supertest
.get(`${getSpaceUrlPrefix(auth.space)}${getCaseFindUserActionsUrl(caseID)}`)
.query(options)
.auth(auth.user.username, auth.user.password)
.expect(expectedHttpCode);
return userActions;
};

View file

@ -58,6 +58,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./internal/bulk_delete_file_attachments'));
loadTestFile(require.resolve('./internal/search_cases'));
loadTestFile(require.resolve('./internal/replace_custom_field'));
loadTestFile(require.resolve('./internal/find_user_actions.ts'));
/**
* Attachments framework