[Cases] Refactor UI user actions (#121962)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2022-01-14 11:38:23 +02:00 committed by GitHub
parent 6956d9c1fe
commit 5a77714c4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 2877 additions and 1620 deletions

View file

@ -110,6 +110,14 @@ export const CommentResponseRt = rt.intersection([
}),
]);
export const CommentResponseTypeUserRt = rt.intersection([
AttributesTypeUserRt,
rt.type({
id: rt.string,
version: rt.string,
}),
]);
export const CommentResponseTypeAlertsRt = rt.intersection([
AttributesTypeAlertsRt,
rt.type({
@ -172,6 +180,7 @@ export type AttributesTypeUser = rt.TypeOf<typeof AttributesTypeUserRt>;
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>;
export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>;
export type CommentResponseUserType = rt.TypeOf<typeof CommentResponseTypeUserRt>;
export type CommentResponseAlertsType = rt.TypeOf<typeof CommentResponseTypeAlertsRt>;
export type CommentResponseActionsType = rt.TypeOf<typeof CommentResponseTypeActionsRt>;
export type AllCommentsResponse = rt.TypeOf<typeof AllCommentsResponseRt>;

View file

@ -12,12 +12,12 @@ import {
CasePatchRequest,
CaseStatuses,
CaseType,
CommentRequest,
User,
ActionConnector,
CaseExternalServiceBasic,
CaseUserActionResponse,
CaseMetricsResponse,
CommentResponse,
} from '../api';
import { SnakeToCamelCase } from '../types';
@ -62,18 +62,7 @@ export type CaseViewRefreshPropInterface = null | {
refreshCase: () => Promise<void>;
};
export type Comment = CommentRequest & {
associationType: AssociationType;
id: string;
createdAt: string;
createdBy: ElasticUser;
pushedAt: string | null;
pushedBy: string | null;
updatedAt: string | null;
updatedBy: ElasticUser | null;
version: string;
};
export type Comment = SnakeToCamelCase<CommentResponse>;
export type CaseUserActions = SnakeToCamelCase<CaseUserActionResponse>;
export type CaseExternalService = SnakeToCamelCase<CaseExternalServiceBasic>;

View file

@ -7,6 +7,7 @@
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { MatcherFunction } from '@testing-library/react';
/**
* Convenience utility to remove text appended to links by EUI
@ -25,3 +26,16 @@ export const waitForComponentToUpdate = async () =>
act(async () => {
return Promise.resolve();
});
type Query = (f: MatcherFunction) => HTMLElement;
export const createQueryWithMarkup =
(query: Query) =>
(text: string): HTMLElement =>
query((content: string, node: Parameters<MatcherFunction>[1]) => {
const hasText = (el: Parameters<MatcherFunction>[1]) => el?.textContent === text;
const childrenDontHaveText = Array.from(node?.children ?? []).every(
(child) => !hasText(child as HTMLElement)
);
return hasText(node) && childrenDontHaveText;
});

View file

@ -792,8 +792,10 @@ describe('AllCasesListGeneric', () => {
</TestProviders>
);
const solutionHeader = wrapper.find({ children: 'Solution' });
expect(solutionHeader.exists()).toBeTruthy();
await waitFor(() => {
const solutionHeader = wrapper.find({ children: 'Solution' });
expect(solutionHeader.exists()).toBeTruthy();
});
});
it('hides Solution column if there is a set owner', async () => {
@ -805,8 +807,10 @@ describe('AllCasesListGeneric', () => {
</TestProviders>
);
const solutionHeader = wrapper.find({ children: 'Solution' });
expect(solutionHeader.exists()).toBeFalsy();
await waitFor(() => {
const solutionHeader = wrapper.find({ children: 'Solution' });
expect(solutionHeader.exists()).toBeFalsy();
});
});
it('should deselect cases when refreshing', async () => {

View file

@ -35,7 +35,7 @@ jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../user_action_tree/user_action_timestamp');
jest.mock('../user_actions/timestamp');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
@ -506,7 +506,7 @@ describe('CaseViewPage', () => {
expect(
wrapper
.find(
'[data-test-subj="comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent'
'[data-test-subj="user-action-alert-comment-create-action-alert-action-id"] .euiCommentEvent__headerEvent'
)
.first()
.text()

View file

@ -18,7 +18,7 @@ import { CaseStatuses, CaseAttributes, CaseType, CaseConnector } from '../../../
import { Case, UpdateKey, UpdateByKey } from '../../../common/ui';
import { EditableTitle } from '../header_page/editable_title';
import { TagList } from '../tag_list';
import { UserActionTree } from '../user_action_tree';
import { UserActions } from '../user_actions';
import { UserList } from '../user_list';
import { useUpdateCase } from '../../containers/use_update_case';
import { getTypedPayload } from '../../containers/utils';
@ -363,12 +363,11 @@ export const CaseViewPage = React.memo<CaseViewPageProps>(
</>
)}
<EuiFlexItem>
<UserActionTree
<UserActions
getRuleDetailsHref={ruleDetailsNavigation?.href}
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
caseServices={caseServices}
caseUserActions={caseUserActions}
connectors={connectors}
data={caseData}
actionsNavigation={actionsNavigation}
fetchUserActions={refetchCaseUserActions}

View file

@ -39,7 +39,7 @@ jest.mock('../../containers/use_get_case');
jest.mock('../../containers/use_get_case_metrics');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../user_action_tree/user_action_timestamp');
jest.mock('../user_actions/timestamp');
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');

View file

@ -12,6 +12,7 @@ import { act } from '@testing-library/react';
import { useForm, Form, FormHook } from '../../common/shared_imports';
import { Description } from './description';
import { schema, FormProps } from './schema';
jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment');
describe('Description', () => {
@ -31,7 +32,7 @@ describe('Description', () => {
};
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
it('it renders', async () => {

View file

@ -68,7 +68,7 @@ describe('CreateCaseForm', () => {
};
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
useGetTagsMock.mockReturnValue({ tags: ['test'] });
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);

View file

@ -5,4 +5,9 @@
* 2.0.
*/
export const useLensDraftComment = () => ({});
export const useLensDraftComment = jest.fn().mockReturnValue({
draftComment: null,
hasIncomingLensState: false,
clearDraftComment: jest.fn(),
openLensModal: jest.fn(),
});

View file

@ -1,8 +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.
*/
export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft';

View file

@ -1,211 +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 React from 'react';
import { mount } from 'enzyme';
import {
Actions,
CaseStatuses,
CommentType,
ConnectorTypes,
ConnectorUserAction,
PushedUserAction,
TagsUserAction,
TitleUserAction,
} from '../../../common/api';
import { basicPush, getUserAction } from '../../containers/mock';
import { getLabelTitle, getPushedServiceLabelTitle, getConnectorLabelTitle } from './helpers';
import { connectorsMock } from '../../containers/configure/mock';
import * as i18n from './translations';
import { SnakeToCamelCase } from '../../../common/types';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
describe('User action tree helpers', () => {
const connectors = connectorsMock;
it('label title generated for update tags', () => {
const action = getUserAction('tags', Actions.update, { payload: { tags: ['test'] } });
const result: string | JSX.Element = getLabelTitle({
action,
});
const tags = (action as unknown as TagsUserAction).payload.tags;
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="ua-tags-label"]`).first().text()).toEqual(
` ${i18n.TAGS.toLowerCase()}`
);
expect(wrapper.find(`[data-test-subj="tag-${tags[0]}"]`).first().text()).toEqual(tags[0]);
});
it('label title generated for update title', () => {
const action = getUserAction('title', Actions.update, { payload: { title: 'test' } });
const result: string | JSX.Element = getLabelTitle({
action,
});
const title = (action as unknown as TitleUserAction).payload.title;
expect(result).toEqual(
`${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${title}"`
);
});
it('label title generated for update description', () => {
const action = getUserAction('description', Actions.update, {
payload: { description: 'test' },
});
const result: string | JSX.Element = getLabelTitle({
action,
});
expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`);
});
it('label title generated for update status to open', () => {
const action = {
...getUserAction('status', Actions.update, { payload: { status: CaseStatuses.open } }),
};
const result: string | JSX.Element = getLabelTitle({
action,
});
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="status-badge-open"]`).first().text()).toEqual('Open');
});
it('label title generated for update status to in-progress', () => {
const action = {
...getUserAction('status', Actions.update, {
payload: { status: CaseStatuses['in-progress'] },
}),
};
const result: string | JSX.Element = getLabelTitle({
action,
});
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="status-badge-in-progress"]`).first().text()).toEqual(
'In progress'
);
});
it('label title generated for update status to closed', () => {
const action = {
...getUserAction('status', Actions.update, {
payload: { status: CaseStatuses.closed },
}),
};
const result: string | JSX.Element = getLabelTitle({
action,
});
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="status-badge-closed"]`).first().text()).toEqual('Closed');
});
it('label title is empty when status is not valid', () => {
const action = {
...getUserAction('status', Actions.update, {
payload: { status: '' },
}),
};
const result: string | JSX.Element = getLabelTitle({
action,
});
expect(result).toEqual('');
});
it('label title generated for update comment', () => {
const action = getUserAction('comment', Actions.update, {
payload: {
comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER },
},
});
const result: string | JSX.Element = getLabelTitle({
action,
});
expect(result).toEqual(`${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`);
});
it('label title generated for pushed incident', () => {
const action = getUserAction('pushed', 'push_to_service', {
payload: { externalService: basicPush },
}) as SnakeToCamelCase<PushedUserAction>;
const result: string | JSX.Element = getPushedServiceLabelTitle(action, true);
const externalService = (action as SnakeToCamelCase<PushedUserAction>).payload.externalService;
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual(
`${i18n.PUSHED_NEW_INCIDENT} ${basicPush.connectorName}`
);
expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual(
externalService.externalUrl
);
});
it('label title generated for needs update incident', () => {
const action = getUserAction('pushed', 'push_to_service') as SnakeToCamelCase<PushedUserAction>;
const result: string | JSX.Element = getPushedServiceLabelTitle(action, false);
const externalService = (action as SnakeToCamelCase<PushedUserAction>).payload.externalService;
const wrapper = mount(<>{result}</>);
expect(wrapper.find(`[data-test-subj="pushed-label"]`).first().text()).toEqual(
`${i18n.UPDATE_INCIDENT} ${basicPush.connectorName}`
);
expect(wrapper.find(`[data-test-subj="pushed-value"]`).first().prop('href')).toEqual(
externalService.externalUrl
);
});
describe('getConnectorLabelTitle', () => {
it('returns an empty string when the encoded value is null', () => {
const result = getConnectorLabelTitle({
// @ts-expect-error
action: getUserAction(['connector'], Actions.update, { payload: { connector: null } }),
connectors,
});
expect(result).toEqual('');
});
it('returns the change connector label', () => {
const result: string | JSX.Element = getConnectorLabelTitle({
action: getUserAction('connector', Actions.update, {
payload: {
connector: {
id: 'resilient-2',
type: ConnectorTypes.resilient,
name: 'a',
fields: null,
},
},
}) as unknown as ConnectorUserAction,
connectors,
});
expect(result).toEqual('selected My Connector 2 as incident management system');
});
it('returns the removed connector label', () => {
const result: string | JSX.Element = getConnectorLabelTitle({
action: getUserAction('connector', Actions.update, {
payload: {
connector: { id: 'none', type: ConnectorTypes.none, name: 'test', fields: null },
},
}) as unknown as ConnectorUserAction,
connectors,
});
expect(result).toEqual('removed external incident management system');
});
});
});

View file

@ -1,411 +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 {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLink,
EuiCommentProps,
EuiToken,
} from '@elastic/eui';
import React, { useContext } from 'react';
import classNames from 'classnames';
import { ThemeContext } from 'styled-components';
import { CaseExternalService, Comment } from '../../../common/ui/types';
import {
ActionConnector,
CaseStatuses,
CommentType,
CommentRequestActionsType,
NONE_CONNECTOR_ID,
Actions,
ConnectorUserAction,
PushedUserAction,
TagsUserAction,
} from '../../../common/api';
import { CaseUserActions } from '../../containers/types';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { Tags } from '../tag_list/tags';
import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionCopyLink } from './user_action_copy_link';
import { ContentWrapper } from './user_action_markdown';
import { UserActionMoveToReference } from './user_action_move_to_reference';
import { Status, statuses } from '../status';
import { UserActionShowAlert } from './user_action_show_alert';
import * as i18n from './translations';
import { AlertCommentEvent } from './user_action_alert_comment_event';
import { CasesNavigation } from '../links';
import { HostIsolationCommentEvent } from './user_action_host_isolation_comment_event';
import { MarkdownRenderer } from '../markdown_editor';
import {
isCommentUserAction,
isDescriptionUserAction,
isStatusUserAction,
isTagsUserAction,
isTitleUserAction,
} from '../../../common/utils/user_actions';
import { SnakeToCamelCase } from '../../../common/types';
interface LabelTitle {
action: CaseUserActions;
}
export type RuleDetailsNavigation = CasesNavigation<string | null | undefined, 'configurable'>;
export type ActionsNavigation = CasesNavigation<string, 'configurable'>;
const getStatusTitle = (id: string, status: CaseStatuses) => (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
data-test-subj={`${id}-user-action-status-title`}
responsive={false}
>
<EuiFlexItem grow={false}>{i18n.MARKED_CASE_AS}</EuiFlexItem>
<EuiFlexItem grow={false}>
<Status type={status} />
</EuiFlexItem>
</EuiFlexGroup>
);
const isStatusValid = (status: string): status is CaseStatuses =>
Object.prototype.hasOwnProperty.call(statuses, status);
export const getLabelTitle = ({ action }: LabelTitle) => {
if (isTagsUserAction(action)) {
return getTagsLabelTitle(action);
} else if (isTitleUserAction(action)) {
return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${
action.payload.title
}"`;
} else if (isDescriptionUserAction(action) && action.action === Actions.update) {
return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`;
} else if (isStatusUserAction(action)) {
const status = action.payload.status ?? '';
if (isStatusValid(status)) {
return getStatusTitle(action.actionId, status);
}
return '';
} else if (isCommentUserAction(action) && action.action === Actions.update) {
return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
}
return '';
};
export const getConnectorLabelTitle = ({
action,
connectors,
}: {
action: ConnectorUserAction;
connectors: ActionConnector[];
}) => {
const connector = action.payload.connector;
if (connector == null) {
return '';
}
// ids are not the same so check and see if the id is a valid connector and then return its name
// if the connector id is the none connector value then it must have been removed
const newConnectorActionInfo = connectors.find((c) => c.id === connector.id);
if (connector.id !== NONE_CONNECTOR_ID && newConnectorActionInfo != null) {
return i18n.SELECTED_THIRD_PARTY(newConnectorActionInfo.name);
}
// it wasn't a valid connector or it was the none connector, so it must have been removed
return i18n.REMOVED_THIRD_PARTY;
};
const getTagsLabelTitle = (action: TagsUserAction) => {
const tags = action.payload.tags ?? [];
return (
<EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span" responsive={false}>
<EuiFlexItem data-test-subj="ua-tags-label" grow={false}>
{action.action === Actions.add && i18n.ADDED_FIELD}
{action.action === Actions.delete && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Tags tags={tags} gutterSize="xs" />
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const getPushedServiceLabelTitle = (
action: SnakeToCamelCase<PushedUserAction>,
firstPush: boolean
) => {
const externalService = action.payload.externalService;
return (
<EuiFlexGroup
alignItems="baseline"
gutterSize="xs"
data-test-subj="pushed-service-label-title"
responsive={false}
>
<EuiFlexItem data-test-subj="pushed-label">
{`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${
externalService?.connectorName
}`}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink data-test-subj="pushed-value" href={externalService?.externalUrl} target="_blank">
{externalService?.externalTitle}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const getPushInfo = (
caseServices: CaseServices,
externalService: CaseExternalService | undefined,
index: number
) =>
externalService != null && externalService.connectorId !== NONE_CONNECTOR_ID
? {
firstPush: caseServices[externalService.connectorId]?.firstPushIndex === index,
parsedConnectorId: externalService.connectorId,
parsedConnectorName: externalService.connectorName,
}
: {
firstPush: false,
parsedConnectorId: NONE_CONNECTOR_ID,
parsedConnectorName: NONE_CONNECTOR_ID,
};
const getUpdateActionIcon = (fields: string): string => {
if (fields === 'tags') {
return 'tag';
} else if (fields === 'status') {
return 'folderClosed';
}
return 'dot';
};
export const getUpdateAction = ({
action,
label,
handleOutlineComment,
}: {
action: CaseUserActions;
label: string | JSX.Element;
handleOutlineComment: (id: string) => void;
}): EuiCommentProps => ({
username: (
<UserActionUsernameWithAvatar
username={action.createdBy.username}
fullName={action.createdBy.fullName}
/>
),
type: 'update',
event: label,
'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.createdAt} />,
timelineIcon: getUpdateActionIcon(action.type),
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
{action.action === Actions.update && action.commentId != null && (
<EuiFlexItem grow={false}>
<UserActionMoveToReference id={action.commentId} outlineComment={handleOutlineComment} />
</EuiFlexItem>
)}
</EuiFlexGroup>
),
});
export const getAlertAttachment = ({
action,
alertId,
getRuleDetailsHref,
index,
loadingAlertData,
onRuleDetailsClick,
onShowAlertDetails,
ruleId,
ruleName,
}: {
action: CaseUserActions;
alertId: string;
getRuleDetailsHref: RuleDetailsNavigation['href'];
index: string;
loadingAlertData: boolean;
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
onShowAlertDetails: (alertId: string, index: string) => void;
ruleId?: string | null;
ruleName?: string | null;
}): EuiCommentProps => ({
username: (
<UserActionUsernameWithAvatar
username={action.createdBy.username}
fullName={action.createdBy.fullName}
/>
),
className: 'comment-alert',
type: 'update',
event: (
<AlertCommentEvent
alertId={alertId}
getRuleDetailsHref={getRuleDetailsHref}
loadingAlertData={loadingAlertData}
onRuleDetailsClick={onRuleDetailsClick}
ruleId={ruleId}
ruleName={ruleName}
commentType={CommentType.alert}
/>
),
'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.createdAt} />,
timelineIcon: 'bell',
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserActionShowAlert
id={action.actionId}
alertId={alertId}
index={index}
onShowAlertDetails={onShowAlertDetails}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
});
export const getGeneratedAlertsAttachment = ({
action,
alertIds,
getRuleDetailsHref,
onRuleDetailsClick,
renderInvestigateInTimelineActionComponent,
ruleId,
ruleName,
}: {
action: CaseUserActions;
alertIds: string[];
getRuleDetailsHref: RuleDetailsNavigation['href'];
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
ruleId: string;
ruleName: string;
}): EuiCommentProps => ({
username: <EuiIcon type="logoSecurity" size="m" />,
className: 'comment-alert',
type: 'update',
event: (
<AlertCommentEvent
alertId={alertIds[0]}
getRuleDetailsHref={getRuleDetailsHref}
onRuleDetailsClick={onRuleDetailsClick}
ruleId={ruleId}
ruleName={ruleName}
alertsCount={alertIds.length}
commentType={CommentType.generatedAlert}
/>
),
'data-test-subj': `${action.type}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.createdAt} />,
timelineIcon: 'bell',
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
{renderInvestigateInTimelineActionComponent ? (
<EuiFlexItem grow={false}>
{renderInvestigateInTimelineActionComponent(alertIds)}
</EuiFlexItem>
) : null}
</EuiFlexGroup>
),
});
const ActionIcon = React.memo<{
actionType: string;
}>(({ actionType }) => {
const theme = useContext(ThemeContext);
return (
<EuiToken
style={{ marginTop: '8px' }}
iconType={actionType === 'isolate' ? 'lock' : 'lockOpen'}
size="m"
shape="circle"
color={theme.eui.euiColorLightestShade}
data-test-subj="endpoint-action-icon"
/>
);
});
ActionIcon.displayName = 'ActionIcon';
export const getActionAttachment = ({
comment,
userCanCrud,
isLoadingIds,
actionsNavigation,
action,
}: {
comment: Comment & CommentRequestActionsType;
userCanCrud: boolean;
isLoadingIds: string[];
actionsNavigation?: ActionsNavigation;
action: CaseUserActions;
}): EuiCommentProps => ({
username: (
<UserActionUsernameWithAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
className: classNames('comment-action', { 'empty-comment': comment.comment.trim().length === 0 }),
event: (
<HostIsolationCommentEvent
type={comment.actions.type}
endpoints={comment.actions.targets}
href={actionsNavigation?.href}
onClick={actionsNavigation?.onClick}
/>
),
'data-test-subj': 'endpoint-action',
timestamp: <UserActionTimestamp createdAt={action.createdAt} />,
timelineIcon: <ActionIcon actionType={comment.actions.type} />,
actions: <UserActionCopyLink id={comment.id} />,
children: comment.comment.trim().length > 0 && (
<ContentWrapper data-test-subj="user-action-markdown">
<MarkdownRenderer>{comment.comment}</MarkdownRenderer>
</ContentWrapper>
),
});
interface Signal {
rule: {
id: string;
name: string;
to: string;
from: string;
};
}
export interface Alert {
_id: string;
_index: string;
'@timestamp': string;
signal: Signal;
[key: string]: unknown;
}

View file

@ -1,689 +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 {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiCommentList,
EuiCommentProps,
} from '@elastic/eui';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import classNames from 'classnames';
import { get, isEmpty } from 'lodash';
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import styled from 'styled-components';
import { isRight } from 'fp-ts/Either';
import * as i18n from './translations';
import { useUpdateComment } from '../../containers/use_update_comment';
import { useCurrentUser } from '../../common/lib/kibana';
import { AddComment, AddCommentRefObject } from '../add_comment';
import { Case, CaseUserActions, Ecs } from '../../../common/ui/types';
import {
ActionConnector,
Actions,
ActionsCommentRequestRt,
AlertCommentRequestRt,
CommentType,
ContextTypeUserRt,
} from '../../../common/api';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import {
getConnectorLabelTitle,
getLabelTitle,
getPushedServiceLabelTitle,
getPushInfo,
getUpdateAction,
getAlertAttachment,
getGeneratedAlertsAttachment,
RuleDetailsNavigation,
ActionsNavigation,
getActionAttachment,
} from './helpers';
import { UserActionAvatar } from './user_action_avatar';
import { UserActionMarkdown, UserActionMarkdownRefObject } from './user_action_markdown';
import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionUsername } from './user_action_username';
import { UserActionContentToolbar } from './user_action_content_toolbar';
import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers';
import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment';
import { useCaseViewParams } from '../../common/navigation';
import { isConnectorUserAction, isPushedUserAction } from '../../../common/utils/user_actions';
import type { OnUpdateFields } from '../case_view/types';
export interface UserActionTreeProps {
caseServices: CaseServices;
caseUserActions: CaseUserActions[];
connectors: ActionConnector[];
data: Case;
fetchUserActions: () => void;
getRuleDetailsHref?: RuleDetailsNavigation['href'];
actionsNavigation?: ActionsNavigation;
isLoadingDescription: boolean;
isLoadingUserActions: boolean;
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
onShowAlertDetails: (alertId: string, index: string) => void;
onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void;
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
statusActionButton: JSX.Element | null;
updateCase: (newCase: Case) => void;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
}
const MyEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 8px;
`;
const MyEuiCommentList = styled(EuiCommentList)`
${({ theme }) => `
& .userAction__comment.outlined .euiCommentEvent {
outline: solid 5px ${theme.eui.euiColorVis1_behindText};
margin: 0.5em;
transition: 0.8s;
}
& .euiComment.isEdit {
& .euiCommentEvent {
border: none;
box-shadow: none;
}
& .euiCommentEvent__body {
padding: 0;
}
& .euiCommentEvent__header {
display: none;
}
}
& .comment-alert .euiCommentEvent {
background-color: ${theme.eui.euiColorLightestShade};
border: ${theme.eui.euiFlyoutBorder};
padding: ${theme.eui.paddingSizes.s};
border-radius: ${theme.eui.paddingSizes.xs};
}
& .comment-alert .euiCommentEvent__headerData {
flex-grow: 1;
}
& .comment-action.empty-comment .euiCommentEvent--regular {
box-shadow: none;
.euiCommentEvent__header {
padding: ${theme.eui.euiSizeM} ${theme.eui.paddingSizes.s};
border-bottom: 0;
}
}
`}
`;
const DESCRIPTION_ID = 'description';
const NEW_ID = 'newComment';
const isAddCommentRef = (
ref: AddCommentRefObject | UserActionMarkdownRefObject | null | undefined
): ref is AddCommentRefObject => {
const commentRef = ref as AddCommentRefObject;
if (commentRef?.addQuote != null) {
return true;
}
return false;
};
export const UserActionTree = React.memo(
({
caseServices,
caseUserActions,
connectors,
data: caseData,
fetchUserActions,
getRuleDetailsHref,
actionsNavigation,
isLoadingDescription,
isLoadingUserActions,
onRuleDetailsClick,
onShowAlertDetails,
onUpdateField,
renderInvestigateInTimelineActionComponent,
statusActionButton,
updateCase,
useFetchAlertData,
userCanCrud,
}: UserActionTreeProps) => {
const { detailName: caseId, subCaseId, commentId } = useCaseViewParams();
const handlerTimeoutId = useRef(0);
const [initLoading, setInitLoading] = useState(true);
const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState('');
const { isLoadingIds, patchComment } = useUpdateComment();
const currentUser = useCurrentUser();
const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState<string[]>([]);
const commentRefs = useRef<
Record<string, AddCommentRefObject | UserActionMarkdownRefObject | undefined | null>
>({});
const { clearDraftComment, draftComment, hasIncomingLensState, openLensModal } =
useLensDraftComment();
const [loadingAlertData, manualAlertsData] = useFetchAlertData(
getManualAlertIdsWithNoRuleId(caseData.comments)
);
const handleManageMarkdownEditId = useCallback(
(id: string) => {
clearDraftComment();
setManageMarkdownEditIds((prevManageMarkdownEditIds) =>
!prevManageMarkdownEditIds.includes(id)
? prevManageMarkdownEditIds.concat(id)
: prevManageMarkdownEditIds.filter((myId) => id !== myId)
);
},
[clearDraftComment]
);
const handleSaveComment = useCallback(
({ id, version }: { id: string; version: string }, content: string) => {
patchComment({
caseId,
commentId: id,
commentUpdate: content,
fetchUserActions,
version,
updateCase,
subCaseId,
});
},
[caseId, fetchUserActions, patchComment, subCaseId, updateCase]
);
const handleOutlineComment = useCallback(
(id: string) => {
const moveToTarget = document.getElementById(`${id}-permLink`);
if (moveToTarget != null) {
const yOffset = -60;
const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({
top: y,
behavior: 'smooth',
});
if (id === 'add-comment') {
moveToTarget.getElementsByTagName('textarea')[0].focus();
}
}
window.clearTimeout(handlerTimeoutId.current);
setSelectedOutlineCommentId(id);
handlerTimeoutId.current = window.setTimeout(() => {
setSelectedOutlineCommentId('');
window.clearTimeout(handlerTimeoutId.current);
}, 2400);
},
[handlerTimeoutId]
);
const handleManageQuote = useCallback(
(quote: string) => {
const ref = commentRefs?.current[NEW_ID];
if (isAddCommentRef(ref)) {
ref.addQuote(quote);
}
handleOutlineComment('add-comment');
},
[handleOutlineComment]
);
const handleUpdate = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchUserActions();
},
[fetchUserActions, updateCase]
);
const MarkdownDescription = useMemo(
() => (
<UserActionMarkdown
ref={(element) => (commentRefs.current[DESCRIPTION_ID] = element)}
id={DESCRIPTION_ID}
content={caseData.description}
isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)}
onSaveContent={(content: string) => {
onUpdateField({ key: DESCRIPTION_ID, value: content });
}}
onChangeEditable={handleManageMarkdownEditId}
/>
),
[caseData.description, handleManageMarkdownEditId, manageMarkdownEditIds, onUpdateField]
);
const MarkdownNewComment = useMemo(
() => (
<AddComment
id={NEW_ID}
caseId={caseId}
userCanCrud={userCanCrud}
ref={(element) => (commentRefs.current[NEW_ID] = element)}
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)}
showLoading={false}
statusActionButton={statusActionButton}
subCaseId={subCaseId}
/>
),
[caseId, userCanCrud, handleUpdate, handleManageMarkdownEditId, statusActionButton, subCaseId]
);
useEffect(() => {
if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) {
setInitLoading(false);
if (commentId != null) {
handleOutlineComment(commentId);
}
}
}, [commentId, initLoading, isLoadingUserActions, isLoadingIds, handleOutlineComment]);
const descriptionCommentListObj: EuiCommentProps = useMemo(
() => ({
username: (
<UserActionUsername
username={caseData.createdBy.username}
fullName={caseData.createdBy.fullName}
/>
),
event: i18n.ADDED_DESCRIPTION,
'data-test-subj': 'description-action',
timestamp: <UserActionTimestamp createdAt={caseData.createdAt} />,
children: MarkdownDescription,
timelineIcon: (
<UserActionAvatar
username={caseData.createdBy.username}
fullName={caseData.createdBy.fullName}
/>
),
className: classNames({
isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID),
}),
actions: (
<UserActionContentToolbar
commentMarkdown={caseData.description}
id={DESCRIPTION_ID}
editLabel={i18n.EDIT_DESCRIPTION}
quoteLabel={i18n.QUOTE}
isLoading={isLoadingDescription}
onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
onQuote={handleManageQuote.bind(null, caseData.description)}
userCanCrud={userCanCrud}
/>
),
}),
[
MarkdownDescription,
caseData,
handleManageMarkdownEditId,
handleManageQuote,
isLoadingDescription,
userCanCrud,
manageMarkdownEditIds,
]
);
const userActions: EuiCommentProps[] = useMemo(
() =>
caseUserActions.reduce<EuiCommentProps[]>(
// TODO: Decrease complexity. https://github.com/elastic/kibana/issues/115730
// eslint-disable-next-line complexity
(comments, action, index) => {
// Comment creation
if (action.commentId != null && action.action === Actions.create) {
const comment = caseData.comments.find((c) => c.id === action.commentId);
if (
comment != null &&
isRight(ContextTypeUserRt.decode(comment)) &&
comment.type === CommentType.user
) {
return [
...comments,
{
username: (
<UserActionUsername
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
'data-test-subj': `comment-create-action-${comment.id}`,
timestamp: (
<UserActionTimestamp
createdAt={comment.createdAt}
updatedAt={comment.updatedAt}
/>
),
className: classNames('userAction__comment', {
outlined: comment.id === selectedOutlineCommentId,
isEdit: manageMarkdownEditIds.includes(comment.id),
}),
children: (
<UserActionMarkdown
ref={(element) => (commentRefs.current[comment.id] = element)}
id={comment.id}
content={comment.comment}
isEditable={manageMarkdownEditIds.includes(comment.id)}
onChangeEditable={handleManageMarkdownEditId}
onSaveContent={handleSaveComment.bind(null, {
id: comment.id,
version: comment.version,
})}
/>
),
timelineIcon: (
<UserActionAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
actions: (
<UserActionContentToolbar
id={comment.id}
commentMarkdown={comment.comment}
editLabel={i18n.EDIT_COMMENT}
quoteLabel={i18n.QUOTE}
isLoading={isLoadingIds.includes(comment.id)}
onEdit={handleManageMarkdownEditId.bind(null, comment.id)}
onQuote={handleManageQuote.bind(null, comment.comment)}
userCanCrud={userCanCrud}
/>
),
},
];
} else if (
comment != null &&
isRight(AlertCommentRequestRt.decode(comment)) &&
comment.type === CommentType.alert
) {
// TODO: clean this up
const alertId = Array.isArray(comment.alertId)
? comment.alertId.length > 0
? comment.alertId[0]
: ''
: comment.alertId;
const alertIndex = Array.isArray(comment.index)
? comment.index.length > 0
? comment.index[0]
: ''
: comment.index;
if (isEmpty(alertId)) {
return comments;
}
const ruleId =
comment?.rule?.id ??
manualAlertsData[alertId]?.signal?.rule?.id?.[0] ??
get(manualAlertsData[alertId], ALERT_RULE_UUID)[0] ??
null;
const ruleName =
comment?.rule?.name ??
manualAlertsData[alertId]?.signal?.rule?.name?.[0] ??
get(manualAlertsData[alertId], ALERT_RULE_NAME)[0] ??
null;
return [
...comments,
...(getRuleDetailsHref != null
? [
getAlertAttachment({
action,
alertId,
getRuleDetailsHref,
index: alertIndex,
loadingAlertData,
onRuleDetailsClick,
ruleId,
ruleName,
onShowAlertDetails,
}),
]
: []),
];
} else if (comment != null && comment.type === CommentType.generatedAlert) {
// TODO: clean this up
const alertIds = Array.isArray(comment.alertId)
? comment.alertId
: [comment.alertId];
if (isEmpty(alertIds)) {
return comments;
}
return [
...comments,
...(getRuleDetailsHref != null
? [
getGeneratedAlertsAttachment({
action,
alertIds,
getRuleDetailsHref,
onRuleDetailsClick,
renderInvestigateInTimelineActionComponent,
ruleId: comment.rule?.id ?? '',
ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE,
}),
]
: []),
];
} else if (
comment != null &&
isRight(ActionsCommentRequestRt.decode(comment)) &&
comment.type === CommentType.actions
) {
return [
...comments,
...(comment.actions !== null
? [
getActionAttachment({
comment,
userCanCrud,
isLoadingIds,
actionsNavigation,
action,
}),
]
: []),
];
}
}
// Connectors
if (isConnectorUserAction(action)) {
const label = getConnectorLabelTitle({ action, connectors });
return [
...comments,
getUpdateAction({
action,
label,
handleOutlineComment,
}),
];
}
// Pushed information
if (isPushedUserAction<'camelCase'>(action)) {
const parsedExternalService = action.payload.externalService;
const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo(
caseServices,
parsedExternalService,
index
);
const label = getPushedServiceLabelTitle(action, firstPush);
const showTopFooter =
action.action === Actions.push_to_service &&
index === caseServices[parsedConnectorId]?.lastPushIndex;
const showBottomFooter =
action.action === Actions.push_to_service &&
index === caseServices[parsedConnectorId]?.lastPushIndex &&
caseServices[parsedConnectorId].hasDataToPush;
let footers: EuiCommentProps[] = [];
if (showTopFooter) {
footers = [
...footers,
{
username: '',
type: 'update',
event: i18n.ALREADY_PUSHED_TO_SERVICE(`${parsedConnectorName}`),
timelineIcon: 'sortUp',
'data-test-subj': 'top-footer',
},
];
}
if (showBottomFooter) {
footers = [
...footers,
{
username: '',
type: 'update',
event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${parsedConnectorName}`),
timelineIcon: 'sortDown',
'data-test-subj': 'bottom-footer',
},
];
}
return [
...comments,
getUpdateAction({
action,
label,
handleOutlineComment,
}),
...footers,
];
}
// title, description, comment updates, tags
if (['title', 'description', 'comment', 'tags', 'status'].includes(action.type)) {
const label: string | JSX.Element = getLabelTitle({
action,
});
return [
...comments,
getUpdateAction({
action,
label,
handleOutlineComment,
}),
];
}
return comments;
},
[descriptionCommentListObj]
),
[
caseUserActions,
descriptionCommentListObj,
caseData.comments,
selectedOutlineCommentId,
manageMarkdownEditIds,
handleManageMarkdownEditId,
handleSaveComment,
actionsNavigation,
userCanCrud,
isLoadingIds,
handleManageQuote,
manualAlertsData,
getRuleDetailsHref,
loadingAlertData,
onRuleDetailsClick,
onShowAlertDetails,
renderInvestigateInTimelineActionComponent,
connectors,
handleOutlineComment,
caseServices,
]
);
const bottomActions = userCanCrud
? [
{
username: (
<UserActionUsername
username={currentUser?.username}
fullName={currentUser?.fullName}
/>
),
'data-test-subj': 'add-comment',
timelineIcon: (
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
),
className: 'isEdit',
children: MarkdownNewComment,
},
]
: [];
const comments = [...userActions, ...bottomActions];
useEffect(() => {
if (draftComment?.commentId) {
setManageMarkdownEditIds((prevManageMarkdownEditIds) => {
if (
![NEW_ID].includes(draftComment?.commentId) &&
!prevManageMarkdownEditIds.includes(draftComment?.commentId)
) {
return [draftComment?.commentId];
}
return prevManageMarkdownEditIds;
});
const ref = commentRefs?.current?.[draftComment.commentId];
if (isAddCommentRef(ref) && ref.editor?.textarea) {
ref.setComment(draftComment.comment);
if (hasIncomingLensState) {
openLensModal({ editorRef: ref.editor });
} else {
clearDraftComment();
}
}
}
}, [
draftComment,
openLensModal,
commentRefs,
hasIncomingLensState,
clearDraftComment,
manageMarkdownEditIds,
]);
return (
<>
<MyEuiCommentList comments={comments} data-test-subj="user-actions" />
{(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && (
<MyEuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="user-actions-loading" size="l" />
</EuiFlexItem>
</MyEuiFlexGroup>
)}
</>
);
}
);
UserActionTree.displayName = 'UserActionTree';

View file

@ -1,121 +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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
import styled from 'styled-components';
import * as i18n from '../case_view/translations';
import { Form, useForm, UseField } from '../../common/shared_imports';
import { schema, Content } from './schema';
import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor';
export const ContentWrapper = styled.div`
padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`};
`;
interface UserActionMarkdownProps {
content: string;
id: string;
isEditable: boolean;
onChangeEditable: (id: string) => void;
onSaveContent: (content: string) => void;
}
export interface UserActionMarkdownRefObject {
setComment: (newComment: string) => void;
}
export const UserActionMarkdown = forwardRef<UserActionMarkdownRefObject, UserActionMarkdownProps>(
({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => {
const editorRef = useRef();
const initialState = { content };
const { form } = useForm<Content>({
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const fieldName = 'content';
const { setFieldValue, submit } = form;
const handleCancelAction = useCallback(() => {
onChangeEditable(id);
}, [id, onChangeEditable]);
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid && data.content !== content) {
onSaveContent(data.content);
}
onChangeEditable(id);
}, [content, id, onChangeEditable, onSaveContent, submit]);
const setComment = useCallback(
(newComment) => {
setFieldValue(fieldName, newComment);
},
[setFieldValue]
);
const EditorButtons = useMemo(
() => (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="user-action-cancel-markdown"
size="s"
onClick={handleCancelAction}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="user-action-save-markdown"
color="success"
fill
iconType="save"
onClick={handleSaveAction}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
[handleCancelAction, handleSaveAction]
);
useImperativeHandle(ref, () => ({
setComment,
editor: editorRef.current,
}));
return isEditable ? (
<Form form={form} data-test-subj="user-action-markdown-form">
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
ref: editorRef,
'aria-label': 'Cases markdown editor',
value: content,
id,
bottomRightContent: EditorButtons,
}}
/>
</Form>
) : (
<ContentWrapper data-test-subj="user-action-markdown">
<MarkdownRenderer>{content}</MarkdownRenderer>
</ContentWrapper>
);
}
);

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionAvatar } from './user_action_avatar';
import { UserActionAvatar } from './avatar';
const props = {
username: 'elastic',

View file

@ -6,19 +6,19 @@
*/
import React, { memo } from 'react';
import { EuiAvatar } from '@elastic/eui';
import { EuiAvatar, EuiAvatarProps } from '@elastic/eui';
import * as i18n from './translations';
interface UserActionAvatarProps {
username?: string | null;
fullName?: string | null;
size?: EuiAvatarProps['size'];
}
const UserActionAvatarComponent = ({ username, fullName }: UserActionAvatarProps) => {
const UserActionAvatarComponent = ({ username, fullName, size = 'm' }: UserActionAvatarProps) => {
const avatarName = fullName && fullName.length > 0 ? fullName : username ?? i18n.UNKNOWN;
return <EuiAvatar name={avatarName} data-test-subj={`user-action-avatar`} />;
return <EuiAvatar name={avatarName} data-test-subj={`user-action-avatar`} size={size} />;
};
export const UserActionAvatar = memo(UserActionAvatarComponent);

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionUsernameWithAvatar } from './user_action_username_with_avatar';
import { UserActionUsernameWithAvatar } from './avatar_username';
const props = {
username: 'elastic',
@ -25,19 +25,15 @@ describe('UserActionUsernameWithAvatar ', () => {
expect(
wrapper.find('[data-test-subj="user-action-username-with-avatar"]').first().exists()
).toBeTruthy();
expect(
wrapper.find('[data-test-subj="user-action-username-avatar"]').first().exists()
).toBeTruthy();
expect(wrapper.find('[data-test-subj="user-action-avatar"]').first().exists()).toBeTruthy();
});
it('it shows the avatar', async () => {
expect(wrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe('E');
expect(wrapper.find('[data-test-subj="user-action-avatar"]').first().text()).toBe('E');
});
it('it shows the avatar without fullName', async () => {
const newWrapper = mount(<UserActionUsernameWithAvatar username="elastic" />);
expect(newWrapper.find('[data-test-subj="user-action-username-avatar"]').first().text()).toBe(
'e'
);
expect(newWrapper.find('[data-test-subj="user-action-avatar"]').first().text()).toBe('e');
});
});

View file

@ -6,11 +6,10 @@
*/
import React, { memo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiAvatar } from '@elastic/eui';
import { isEmpty } from 'lodash/fp';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { UserActionUsername } from './user_action_username';
import * as i18n from './translations';
import { UserActionAvatar } from './avatar';
import { UserActionUsername } from './username';
interface UserActionUsernameWithAvatarProps {
username?: string | null;
@ -28,11 +27,7 @@ const UserActionUsernameWithAvatarComponent = ({
data-test-subj="user-action-username-with-avatar"
>
<EuiFlexItem grow={false}>
<EuiAvatar
size="s"
name={(isEmpty(fullName) ? username : fullName) ?? i18n.UNKNOWN}
data-test-subj="user-action-username-avatar"
/>
<UserActionAvatar username={username} fullName={fullName} size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserActionUsername username={username} fullName={fullName} />

View file

@ -0,0 +1,25 @@
/*
* 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 { createCommentUserActionBuilder } from './comment/comment';
import { createConnectorUserActionBuilder } from './connector';
import { createDescriptionUserActionBuilder } from './description';
import { createPushedUserActionBuilder } from './pushed';
import { createStatusUserActionBuilder } from './status';
import { createTagsUserActionBuilder } from './tags';
import { createTitleUserActionBuilder } from './title';
import { UserActionBuilderMap } from './types';
export const builderMap: UserActionBuilderMap = {
connector: createConnectorUserActionBuilder,
tags: createTagsUserActionBuilder,
title: createTitleUserActionBuilder,
status: createStatusUserActionBuilder,
pushed: createPushedUserActionBuilder,
comment: createCommentUserActionBuilder,
description: createDescriptionUserActionBuilder,
};

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useContext } from 'react';
import classNames from 'classnames';
import { ThemeContext } from 'styled-components';
import { EuiToken } from '@elastic/eui';
import { CommentResponseActionsType } from '../../../../common/api';
import { UserActionBuilder, UserActionBuilderArgs } from '../types';
import { UserActionTimestamp } from '../timestamp';
import { SnakeToCamelCase } from '../../../../common/types';
import { UserActionUsernameWithAvatar } from '../avatar_username';
import { UserActionCopyLink } from '../copy_link';
import { MarkdownRenderer } from '../../markdown_editor';
import { ContentWrapper } from '../markdown_form';
import { HostIsolationCommentEvent } from './host_isolation_event';
type BuilderArgs = Pick<UserActionBuilderArgs, 'userAction' | 'actionsNavigation'> & {
comment: SnakeToCamelCase<CommentResponseActionsType>;
};
export const createActionAttachmentUserActionBuilder = ({
userAction,
comment,
actionsNavigation,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => {
return [
{
username: (
<UserActionUsernameWithAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
className: classNames('comment-action', {
'empty-comment': comment.comment.trim().length === 0,
}),
event: (
<HostIsolationCommentEvent
type={comment.actions.type}
endpoints={comment.actions.targets}
href={actionsNavigation?.href}
onClick={actionsNavigation?.onClick}
/>
),
'data-test-subj': 'endpoint-action',
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineIcon: <ActionIcon actionType={comment.actions.type} />,
actions: <UserActionCopyLink id={comment.id} />,
children: comment.comment.trim().length > 0 && (
<ContentWrapper data-test-subj="user-action-markdown">
<MarkdownRenderer>{comment.comment}</MarkdownRenderer>
</ContentWrapper>
),
},
];
},
});
const ActionIcon = React.memo<{
actionType: string;
}>(({ actionType }) => {
const theme = useContext(ThemeContext);
return (
<EuiToken
style={{ marginTop: '8px' }}
iconType={actionType === 'isolate' ? 'lock' : 'lockOpen'}
size="m"
shape="circle"
color={theme.eui.euiColorLightestShade}
data-test-subj="endpoint-action-icon"
/>
);
});
ActionIcon.displayName = 'ActionIcon';

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { get, isEmpty } from 'lodash';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { CommentType, CommentResponseAlertsType } from '../../../../common/api';
import { UserActionBuilder, UserActionBuilderArgs } from '../types';
import { UserActionTimestamp } from '../timestamp';
import { SnakeToCamelCase } from '../../../../common/types';
import { UserActionUsernameWithAvatar } from '../avatar_username';
import { AlertCommentEvent } from './alert_event';
import { UserActionCopyLink } from '../copy_link';
import { UserActionShowAlert } from './show_alert';
type BuilderArgs = Pick<
UserActionBuilderArgs,
| 'userAction'
| 'alertData'
| 'getRuleDetailsHref'
| 'onRuleDetailsClick'
| 'loadingAlertData'
| 'onShowAlertDetails'
> & { comment: SnakeToCamelCase<CommentResponseAlertsType> };
const getFirstItem = (items: string | string[]) =>
Array.isArray(items) ? (items.length > 0 ? items[0] : '') : items;
export const createAlertAttachmentUserActionBuilder = ({
userAction,
comment,
alertData,
getRuleDetailsHref,
loadingAlertData,
onRuleDetailsClick,
onShowAlertDetails,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => {
const alertId = getFirstItem(comment.alertId);
const alertIndex = getFirstItem(comment.index);
if (isEmpty(alertId)) {
return [];
}
const ruleId =
comment?.rule?.id ??
alertData[alertId]?.signal?.rule?.id?.[0] ??
get(alertData[alertId], ALERT_RULE_UUID)[0] ??
null;
const ruleName =
comment?.rule?.name ??
alertData[alertId]?.signal?.rule?.name?.[0] ??
get(alertData[alertId], ALERT_RULE_NAME)[0] ??
null;
return [
{
username: (
<UserActionUsernameWithAvatar
username={userAction.createdBy.username}
fullName={userAction.createdBy.fullName}
/>
),
className: 'comment-alert',
type: 'update',
event: (
<AlertCommentEvent
alertId={alertId}
getRuleDetailsHref={getRuleDetailsHref}
loadingAlertData={loadingAlertData}
onRuleDetailsClick={onRuleDetailsClick}
ruleId={ruleId}
ruleName={ruleName}
commentType={CommentType.alert}
/>
),
'data-test-subj': `user-action-alert-${userAction.type}-${userAction.action}-action-${userAction.actionId}`,
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineIcon: 'bell',
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={userAction.actionId} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UserActionShowAlert
id={userAction.actionId}
alertId={alertId}
index={alertIndex}
onShowAlertDetails={onShowAlertDetails}
/>
</EuiFlexItem>
</EuiFlexGroup>
),
},
];
},
});

View file

@ -8,14 +8,14 @@
import React from 'react';
import { mount } from 'enzyme';
import { TestProviders } from '../../common/mock';
import { useKibana } from '../../common/lib/kibana';
import { AlertCommentEvent } from './user_action_alert_comment_event';
import { CommentType } from '../../../common/api';
import { TestProviders } from '../../../common/mock';
import { useKibana } from '../../../common/lib/kibana';
import { AlertCommentEvent } from './alert_event';
import { CommentType } from '../../../../common/api';
const props = {
alertId: 'alert-id-1',
getRuleDetailsHref: jest.fn().mockReturnValue('some-detection-rule-link'),
getRuleDetailsHref: jest.fn().mockReturnValue('https://example.com'),
onRuleDetailsClick: jest.fn(),
ruleId: 'rule-id-1',
ruleName: 'Awesome rule',
@ -23,7 +23,7 @@ const props = {
commentType: CommentType.alert,
};
jest.mock('../../common/lib/kibana');
jest.mock('../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('UserActionAvatar ', () => {
@ -59,6 +59,33 @@ describe('UserActionAvatar ', () => {
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
).toBeFalsy();
expect(wrapper.text()).toBe('added an alert from Awesome rule');
});
it('does NOT render the link when the href is invalid but it shows the rule name', async () => {
const wrapper = mount(
<TestProviders>
<AlertCommentEvent {...props} getRuleDetailsHref={undefined} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
).toBeFalsy();
expect(wrapper.text()).toBe('added an alert from Awesome rule');
});
it('show Unknown rule if the rule name is invalid', async () => {
const wrapper = mount(
<TestProviders>
<AlertCommentEvent {...props} ruleName={null} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
).toBeTruthy();
expect(wrapper.text()).toBe('added an alert from Unknown rule');
});

View file

@ -7,17 +7,17 @@
import React, { memo, useCallback } from 'react';
import { isEmpty } from 'lodash';
import { EuiText, EuiLoadingSpinner } from '@elastic/eui';
import { EuiLoadingSpinner } from '@elastic/eui';
import * as i18n from './translations';
import { CommentType } from '../../../common/api';
import { LinkAnchor } from '../links';
import { RuleDetailsNavigation } from './helpers';
import { CommentType } from '../../../../common/api';
import * as i18n from '../translations';
import { LinkAnchor } from '../../links';
import { RuleDetailsNavigation } from '../types';
interface Props {
alertId: string;
commentType: CommentType;
getRuleDetailsHref: RuleDetailsNavigation['href'];
getRuleDetailsHref?: RuleDetailsNavigation['href'];
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
ruleId?: string | null;
ruleName?: string | null;
@ -42,38 +42,24 @@ const AlertCommentEventComponent: React.FC<Props> = ({
},
[ruleId, onRuleDetailsClick]
);
const detectionsRuleDetailsHref = getRuleDetailsHref(ruleId);
const detectionsRuleDetailsHref = getRuleDetailsHref?.(ruleId);
const finalRuleName = ruleName ?? i18n.UNKNOWN_RULE;
return commentType !== CommentType.generatedAlert ? (
return (
<>
{`${i18n.ALERT_COMMENT_LABEL_TITLE} `}
{loadingAlertData && <EuiLoadingSpinner size="m" />}
{!loadingAlertData && !isEmpty(ruleId) && (
{!loadingAlertData && !isEmpty(ruleId) && detectionsRuleDetailsHref != null && (
<LinkAnchor
onClick={onLinkClick}
href={detectionsRuleDetailsHref}
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
>
{ruleName ?? i18n.UNKNOWN_RULE}
{finalRuleName}
</LinkAnchor>
)}
{!loadingAlertData && isEmpty(ruleId) && i18n.UNKNOWN_RULE}
</>
) : (
<>
<b>{i18n.GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE(alertsCount ?? 0)}</b>{' '}
{i18n.GENERATED_ALERT_COMMENT_LABEL_TITLE}{' '}
{loadingAlertData && <EuiLoadingSpinner size="m" />}
{!loadingAlertData && ruleId !== '' && (
<LinkAnchor
onClick={onLinkClick}
href={detectionsRuleDetailsHref}
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
>
{ruleName}
</LinkAnchor>
)}
{!loadingAlertData && ruleId === '' && <EuiText>{ruleName}</EuiText>}
{!loadingAlertData && !isEmpty(ruleId) && detectionsRuleDetailsHref == null && finalRuleName}
{!loadingAlertData && isEmpty(ruleId) && finalRuleName}
</>
);
};

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions } from '../../../../common/api';
import {
alertComment,
basicCase,
getAlertUserAction,
getHostIsolationUserAction,
getUserAction,
hostIsolationComment,
} from '../../../containers/mock';
import { TestProviders } from '../../../common/mock';
import { createCommentUserActionBuilder } from './comment';
import { getMockBuilderArgs } from '../mock';
jest.mock('../../../common/lib/kibana');
jest.mock('../../../common/navigation/hooks');
describe('createCommentUserActionBuilder', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly when editing a comment', async () => {
const userAction = getUserAction('comment', Actions.update);
const builder = createCommentUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('edited comment')).toBeInTheDocument();
});
it('renders correctly a user comment', async () => {
const userAction = getUserAction('comment', Actions.create, {
commentId: basicCase.comments[0].id,
});
const builder = createCommentUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('Solve this fast!')).toBeInTheDocument();
});
it('renders correctly an alert', async () => {
const userAction = getAlertUserAction();
const builder = createCommentUserActionBuilder({
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [alertComment],
},
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('added an alert from')).toBeInTheDocument();
expect(screen.getByText('Awesome rule')).toBeInTheDocument();
});
it('renders correctly an action', async () => {
const userAction = getHostIsolationUserAction();
const builder = createCommentUserActionBuilder({
...builderArgs,
caseData: {
...builderArgs.caseData,
comments: [hostIsolationComment()],
},
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('submitted isolate request on host')).toBeInTheDocument();
expect(screen.getByText('host1')).toBeInTheDocument();
expect(screen.getByText('I just isolated the host!')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,142 @@
/*
* 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 { EuiCommentProps } from '@elastic/eui';
import { CommentUserAction, Actions, CommentType } from '../../../../common/api';
import { UserActionBuilder, UserActionBuilderArgs, UserActionResponse } from '../types';
import { createCommonUpdateUserActionBuilder } from '../common';
import { Comment } from '../../../containers/types';
import * as i18n from '../translations';
import { createUserAttachmentUserActionBuilder } from './user';
import { createAlertAttachmentUserActionBuilder } from './alert';
import { createActionAttachmentUserActionBuilder } from './actions';
const getUpdateLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`;
const getCreateCommentUserAction = ({
userAction,
comment,
userCanCrud,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
getRuleDetailsHref,
loadingAlertData,
onRuleDetailsClick,
alertData,
onShowAlertDetails,
actionsNavigation,
}: {
userAction: UserActionResponse<CommentUserAction>;
comment: Comment;
} & Omit<
UserActionBuilderArgs,
'caseData' | 'caseServices' | 'comments' | 'index' | 'handleOutlineComment'
>): EuiCommentProps[] => {
switch (comment.type) {
case CommentType.user:
const userBuilder = createUserAttachmentUserActionBuilder({
comment,
userCanCrud,
outlined: comment.id === selectedOutlineCommentId,
isEdit: manageMarkdownEditIds.includes(comment.id),
commentRefs,
isLoading: loadingCommentIds.includes(comment.id),
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
});
return userBuilder.build();
case CommentType.alert:
const alertBuilder = createAlertAttachmentUserActionBuilder({
alertData,
comment,
userAction,
getRuleDetailsHref,
loadingAlertData,
onRuleDetailsClick,
onShowAlertDetails,
});
return alertBuilder.build();
case CommentType.actions:
const actionBuilder = createActionAttachmentUserActionBuilder({
userAction,
comment,
actionsNavigation,
});
return actionBuilder.build();
default:
return [];
}
};
export const createCommentUserActionBuilder: UserActionBuilder = ({
caseData,
userAction,
userCanCrud,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
alertData,
getRuleDetailsHref,
onRuleDetailsClick,
onShowAlertDetails,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
handleOutlineComment,
}) => ({
build: () => {
const commentUserAction = userAction as UserActionResponse<CommentUserAction>;
const comment = caseData.comments.find((c) => c.id === commentUserAction.commentId);
if (comment == null) {
return [];
}
if (commentUserAction.action === Actions.create) {
const commentAction = getCreateCommentUserAction({
userAction: commentUserAction,
comment,
userCanCrud,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
alertData,
getRuleDetailsHref,
onRuleDetailsClick,
onShowAlertDetails,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
});
return commentAction;
}
const label = getUpdateLabelTitle();
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'dot',
});
return commonBuilder.build();
},
});

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { HostIsolationCommentEvent } from './user_action_host_isolation_comment_event';
import { HostIsolationCommentEvent } from './host_isolation_event';
const defaultProps = () => {
return {

View file

@ -6,9 +6,9 @@
*/
import React, { memo, useCallback } from 'react';
import * as i18n from './translations';
import { LinkAnchor } from '../links';
import { ActionsNavigation } from './helpers';
import * as i18n from '../translations';
import { LinkAnchor } from '../../links';
import { ActionsNavigation } from '../types';
interface EndpointInfo {
endpointId: string;

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionShowAlert } from './user_action_show_alert';
import { UserActionShowAlert } from './show_alert';
const props = {
id: 'action-id',

View file

@ -7,7 +7,7 @@
import React, { memo, useCallback } from 'react';
import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
import * as i18n from './translations';
import * as i18n from '../translations';
interface UserActionShowAlertProps {
id: string;

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import classNames from 'classnames';
import { CommentResponseUserType } from '../../../../common/api';
import { UserActionTimestamp } from '../timestamp';
import { SnakeToCamelCase } from '../../../../common/types';
import { UserActionMarkdown } from '../markdown_form';
import { UserActionAvatar } from '../avatar';
import { UserActionContentToolbar } from '../content_toolbar';
import { UserActionUsername } from '../username';
import * as i18n from '../translations';
import { UserActionBuilderArgs, UserActionBuilder } from '../types';
type BuilderArgs = Pick<
UserActionBuilderArgs,
| 'userCanCrud'
| 'handleManageMarkdownEditId'
| 'handleSaveComment'
| 'handleManageQuote'
| 'commentRefs'
> & {
comment: SnakeToCamelCase<CommentResponseUserType>;
outlined: boolean;
isEdit: boolean;
isLoading: boolean;
};
export const createUserAttachmentUserActionBuilder = ({
comment,
userCanCrud,
outlined,
isEdit,
isLoading,
commentRefs,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => [
{
username: (
<UserActionUsername
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
'data-test-subj': `comment-create-action-${comment.id}`,
timestamp: (
<UserActionTimestamp createdAt={comment.createdAt} updatedAt={comment.updatedAt} />
),
className: classNames('userAction__comment', {
outlined,
isEdit,
}),
children: (
<UserActionMarkdown
ref={(element) => (commentRefs.current[comment.id] = element)}
id={comment.id}
content={comment.comment}
isEditable={isEdit}
onChangeEditable={handleManageMarkdownEditId}
onSaveContent={handleSaveComment.bind(null, {
id: comment.id,
version: comment.version,
})}
/>
),
timelineIcon: (
<UserActionAvatar
username={comment.createdBy.username}
fullName={comment.createdBy.fullName}
/>
),
actions: (
<UserActionContentToolbar
id={comment.id}
commentMarkdown={comment.comment}
editLabel={i18n.EDIT_COMMENT}
quoteLabel={i18n.QUOTE}
isLoading={isLoading}
onEdit={handleManageMarkdownEditId.bind(null, comment.id)}
onQuote={handleManageQuote.bind(null, comment.comment)}
userCanCrud={userCanCrud}
/>
),
},
],
});

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import copy from 'copy-to-clipboard';
import { Actions } from '../../../common/api';
import { createCommonUpdateUserActionBuilder } from './common';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
jest.mock('copy-to-clipboard', () => jest.fn());
describe('createCommonUpdateUserActionBuilder ', () => {
const label = <>{'A label'}</>;
const handleOutlineComment = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
const userAction = getUserAction('title', Actions.update);
const builder = createCommonUpdateUserActionBuilder({
userAction,
label,
icon: 'dot',
handleOutlineComment,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
// The avatar
expect(screen.getByText('LK')).toBeInTheDocument();
// The username
expect(screen.getByText(userAction.createdBy.username!)).toBeInTheDocument();
// The label of the event
expect(screen.getByText('A label')).toBeInTheDocument();
// The copy link button
expect(screen.getByLabelText('Copy reference link')).toBeInTheDocument();
});
it('renders shows the move to comment button if the user action is an edit comment', async () => {
const userAction = getUserAction('comment', Actions.update);
const builder = createCommonUpdateUserActionBuilder({
userAction,
label,
icon: 'dot',
handleOutlineComment,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByLabelText('Highlight the referenced comment')).toBeInTheDocument();
});
it('it copies the reference link when clicking the reference button', async () => {
const userAction = getUserAction('comment', Actions.update);
const builder = createCommonUpdateUserActionBuilder({
userAction,
label,
icon: 'dot',
handleOutlineComment,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
userEvent.click(screen.getByLabelText('Copy reference link'));
expect(copy).toHaveBeenCalled();
});
it('calls the handleOutlineComment when clicking the reference button', async () => {
const userAction = getUserAction('comment', Actions.update);
const builder = createCommonUpdateUserActionBuilder({
userAction,
label,
icon: 'dot',
handleOutlineComment,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
userEvent.click(screen.getByLabelText('Highlight the referenced comment'));
expect(handleOutlineComment).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentProps, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Actions, ConnectorUserAction, UserAction } from '../../../common/api';
import { UserActionTimestamp } from './timestamp';
import { UserActionBuilder, UserActionBuilderArgs, UserActionResponse } from './types';
import { UserActionUsernameWithAvatar } from './avatar_username';
import { UserActionCopyLink } from './copy_link';
import { UserActionMoveToReference } from './move_to_reference';
interface Props {
userAction: UserActionResponse<ConnectorUserAction>;
handleOutlineComment: (id: string) => void;
}
const showMoveToReference = (action: UserAction, commentId: string | null): commentId is string =>
action === Actions.update && commentId != null;
const CommentListActions: React.FC<Props> = React.memo(({ userAction, handleOutlineComment }) => (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={userAction.actionId} />
</EuiFlexItem>
{showMoveToReference(userAction.action, userAction.commentId) && (
<EuiFlexItem grow={false}>
<UserActionMoveToReference
id={userAction.commentId}
outlineComment={handleOutlineComment}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
));
CommentListActions.displayName = 'CommentListActions';
type BuilderArgs = Pick<UserActionBuilderArgs, 'userAction' | 'handleOutlineComment'> & {
label: EuiCommentProps['event'];
icon: EuiCommentProps['timelineIcon'];
};
export const createCommonUpdateUserActionBuilder = ({
userAction,
label,
icon,
handleOutlineComment,
}: BuilderArgs): ReturnType<UserActionBuilder> => ({
build: () => [
{
username: (
<UserActionUsernameWithAvatar
username={userAction.createdBy.username}
fullName={userAction.createdBy.fullName}
/>
),
type: 'update' as const,
event: label,
'data-test-subj': `${userAction.type}-${userAction.action}-action-${userAction.actionId}`,
timestamp: <UserActionTimestamp createdAt={userAction.createdAt} />,
timelineIcon: icon,
actions: (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<UserActionCopyLink id={userAction.actionId} />
</EuiFlexItem>
{showMoveToReference(userAction.action, userAction.commentId) && (
<EuiFlexItem grow={false}>
<UserActionMoveToReference
id={userAction.commentId}
outlineComment={handleOutlineComment}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
},
],
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions, NONE_CONNECTOR_ID } from '../../../common/api';
import { getUserAction, getJiraConnector } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createConnectorUserActionBuilder } from './connector';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createConnectorUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
const userAction = getUserAction('connector', Actions.update, {
payload: { connector: getJiraConnector() },
});
const builder = createConnectorUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('selected jira1 as incident management system')).toBeInTheDocument();
});
it('renders the removed connector label if the connector is none', async () => {
const userAction = getUserAction('connector', Actions.update, {
payload: { connector: { ...getJiraConnector(), id: NONE_CONNECTOR_ID } },
});
const builder = createConnectorUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('removed external incident management system')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ConnectorUserAction, NONE_CONNECTOR_ID } from '../../../common/api';
import { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import * as i18n from './translations';
const getLabelTitle = (userAction: UserActionResponse<ConnectorUserAction>) => {
const connector = userAction.payload.connector;
if (connector == null) {
return '';
}
if (connector.id === NONE_CONNECTOR_ID) {
return i18n.REMOVED_THIRD_PARTY;
}
return i18n.SELECTED_THIRD_PARTY(connector.name);
};
export const createConnectorUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
const connectorUserAction = userAction as UserActionResponse<ConnectorUserAction>;
const label = getLabelTitle(connectorUserAction);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'dot',
});
return commonBuilder.build();
},
});

View file

@ -0,0 +1,19 @@
/*
* 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 { omit } from 'lodash';
import { ActionTypes } from '../../../common/api';
import { SupportedUserActionTypes } from './types';
export const DRAFT_COMMENT_STORAGE_ID = 'xpack.cases.commentDraft';
export const UNSUPPORTED_ACTION_TYPES = ['create_case', 'delete_case', 'settings'] as const;
export const SUPPORTED_ACTION_TYPES: SupportedUserActionTypes[] = Object.keys(
omit(ActionTypes, UNSUPPORTED_ACTION_TYPES)
) as SupportedUserActionTypes[];
export const NEW_COMMENT_ID = 'newComment';

View file

@ -7,10 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import {
UserActionContentToolbar,
UserActionContentToolbarProps,
} from './user_action_content_toolbar';
import { UserActionContentToolbar, UserActionContentToolbarProps } from './content_toolbar';
jest.mock('../../common/navigation/hooks');
jest.mock('../../common/lib/kibana');

View file

@ -8,8 +8,8 @@
import React, { memo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionPropertyActions } from './user_action_property_actions';
import { UserActionCopyLink } from './copy_link';
import { UserActionPropertyActions } from './property_actions';
export interface UserActionContentToolbarProps {
commentMarkdown: string;

View file

@ -11,7 +11,7 @@ import copy from 'copy-to-clipboard';
import { useKibana } from '../../common/lib/kibana';
import { TestProviders } from '../../common/mock';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionCopyLink } from './copy_link';
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createDescriptionUserActionBuilder } from './description';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createDescriptionUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly when editing a description', async () => {
const userAction = getUserAction('description', Actions.update);
const builder = createDescriptionUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('edited description')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import classNames from 'classnames';
import { EuiCommentProps } from '@elastic/eui';
import type { UserActionBuilder, UserActionBuilderArgs, UserActionTreeProps } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import { UserActionUsername } from './username';
import { UserActionAvatar } from './avatar';
import { UserActionContentToolbar } from './content_toolbar';
import { UserActionTimestamp } from './timestamp';
import { UserActionMarkdown } from './markdown_form';
import * as i18n from './translations';
const DESCRIPTION_ID = 'description';
const getLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`;
type GetDescriptionUserActionArgs = Pick<
UserActionBuilderArgs,
| 'caseData'
| 'commentRefs'
| 'manageMarkdownEditIds'
| 'userCanCrud'
| 'handleManageMarkdownEditId'
| 'handleManageQuote'
> &
Pick<UserActionTreeProps, 'onUpdateField' | 'isLoadingDescription'>;
export const getDescriptionUserAction = ({
caseData,
commentRefs,
manageMarkdownEditIds,
isLoadingDescription,
userCanCrud,
onUpdateField,
handleManageMarkdownEditId,
handleManageQuote,
}: GetDescriptionUserActionArgs): EuiCommentProps => {
return {
username: (
<UserActionUsername
username={caseData.createdBy.username}
fullName={caseData.createdBy.fullName}
/>
),
event: i18n.ADDED_DESCRIPTION,
'data-test-subj': 'description-action',
timestamp: <UserActionTimestamp createdAt={caseData.createdAt} />,
children: (
<UserActionMarkdown
ref={(element) => (commentRefs.current[DESCRIPTION_ID] = element)}
id={DESCRIPTION_ID}
content={caseData.description}
isEditable={manageMarkdownEditIds.includes(DESCRIPTION_ID)}
onSaveContent={(content: string) => {
onUpdateField({ key: DESCRIPTION_ID, value: content });
}}
onChangeEditable={handleManageMarkdownEditId}
/>
),
timelineIcon: (
<UserActionAvatar
username={caseData.createdBy.username}
fullName={caseData.createdBy.fullName}
/>
),
className: classNames({
isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID),
}),
actions: (
<UserActionContentToolbar
commentMarkdown={caseData.description}
id={DESCRIPTION_ID}
editLabel={i18n.EDIT_DESCRIPTION}
quoteLabel={i18n.QUOTE}
isLoading={isLoadingDescription}
onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)}
onQuote={handleManageQuote.bind(null, caseData.description)}
userCanCrud={userCanCrud}
/>
),
};
};
export const createDescriptionUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
const label = getLabelTitle();
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'dot',
});
return commonBuilder.build();
},
});

View file

@ -8,8 +8,7 @@
import { AssociationType, CommentType } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { Comment } from '../../containers/types';
import { getManualAlertIdsWithNoRuleId } from './helpers';
import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers';
const comments: Comment[] = [
{
@ -19,7 +18,7 @@ const comments: Comment[] = [
index: 'alert-index-1',
id: 'comment-id',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: { username: 'elastic' },
createdBy: { username: 'elastic', email: 'elastic@elastic.co', fullName: 'Elastic' },
rule: {
id: null,
name: null,
@ -38,7 +37,7 @@ const comments: Comment[] = [
index: 'alert-index-2',
id: 'comment-id',
createdAt: '2020-02-19T23:06:33.798Z',
createdBy: { username: 'elastic' },
createdBy: { username: 'elastic', email: 'elastic@elastic.co', fullName: 'Elastic' },
pushedAt: null,
pushedBy: null,
rule: {
@ -52,7 +51,28 @@ const comments: Comment[] = [
},
];
describe('Case view helpers', () => {
describe('Case view helpers', () => {});
describe('helpers', () => {
describe('isUserActionTypeSupported', () => {
const types: Array<[string, boolean]> = [
['comment', true],
['connector', true],
['description', true],
['pushed', true],
['tags', true],
['title', true],
['status', true],
['settings', false],
['create_case', false],
['delete_case', false],
];
it.each(types)('determines if the type is support %s', (type, supported) => {
expect(isUserActionTypeSupported(type)).toBe(supported);
});
});
describe('getAlertIdsFromComments', () => {
it('it returns the alert id from the comments where rule is not defined', () => {
expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']);

View file

@ -8,6 +8,11 @@
import { isEmpty } from 'lodash';
import { CommentType } from '../../../common/api';
import type { Comment } from '../../containers/types';
import { SUPPORTED_ACTION_TYPES } from './constants';
import { SupportedUserActionTypes } from './types';
export const isUserActionTypeSupported = (type: string): type is SupportedUserActionTypes =>
SUPPORTED_ACTION_TYPES.includes(type as SupportedUserActionTypes);
export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => {
const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => {

View file

@ -20,7 +20,7 @@ import {
hostIsolationComment,
hostReleaseComment,
} from '../../containers/mock';
import { UserActionTree } from '.';
import { UserActions } from '.';
import { TestProviders } from '../../common/mock';
import { Ecs } from '../../../common/ui/types';
import { Actions } from '../../../common/api';
@ -53,23 +53,25 @@ const defaultProps = {
alerts: {},
onShowAlertDetails,
};
const useUpdateCommentMock = useUpdateComment as jest.Mock;
jest.mock('../../containers/use_update_comment');
jest.mock('./user_action_timestamp');
jest.mock('./timestamp');
jest.mock('../../common/lib/kibana');
const useUpdateCommentMock = useUpdateComment as jest.Mock;
const patchComment = jest.fn();
describe(`UserActionTree`, () => {
describe(`UserActions`, () => {
const sampleData = {
content: 'what a great comment update',
};
beforeEach(() => {
jest.clearAllMocks();
useUpdateCommentMock.mockImplementation(() => ({
useUpdateCommentMock.mockReturnValue({
isLoadingIds: [],
patchComment,
}));
});
jest
.spyOn(routeData, 'useParams')
@ -79,15 +81,14 @@ describe(`UserActionTree`, () => {
it('Loading spinner when user actions loading and displays fullName/username', () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...{ ...defaultProps, isLoadingUserActions: true }} />
<UserActions {...{ ...defaultProps, isLoadingUserActions: true }} />
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toEqual(true);
expect(wrapper.find(`[data-test-subj="user-actions-loading"]`).exists()).toEqual(true);
expect(wrapper.find(`[data-test-subj="user-action-avatar"]`).first().prop('name')).toEqual(
defaultProps.data.createdBy.fullName
);
expect(
wrapper.find(`[data-test-subj="description-action"] figcaption strong`).first().text()
).toEqual(defaultProps.data.createdBy.username);
@ -114,7 +115,7 @@ describe(`UserActionTree`, () => {
};
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -141,7 +142,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -161,7 +162,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
expect(
@ -196,7 +197,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
@ -240,7 +241,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
@ -296,7 +297,7 @@ describe(`UserActionTree`, () => {
it('calls update description when description markdown is saved', async () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...defaultProps} />
<UserActions {...defaultProps} />
</TestProviders>
);
@ -340,7 +341,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...defaultProps} />
<UserActions {...defaultProps} />
</TestProviders>
);
@ -373,7 +374,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -397,7 +398,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -415,7 +416,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -435,7 +436,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {
@ -454,7 +455,7 @@ describe(`UserActionTree`, () => {
const wrapper = mount(
<TestProviders>
<UserActionTree {...props} />
<UserActions {...props} />
</TestProviders>
);
await waitFor(() => {

View file

@ -0,0 +1,275 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiCommentList,
EuiCommentProps,
} from '@elastic/eui';
import React, { useMemo, useState, useEffect } from 'react';
import styled from 'styled-components';
import { useCurrentUser } from '../../common/lib/kibana';
import { AddComment } from '../add_comment';
import { UserActionAvatar } from './avatar';
import { UserActionUsername } from './username';
import { useCaseViewParams } from '../../common/navigation';
import { builderMap } from './builder';
import { isUserActionTypeSupported, getManualAlertIdsWithNoRuleId } from './helpers';
import type { UserActionTreeProps } from './types';
import { getDescriptionUserAction } from './description';
import { useUserActionsHandler } from './use_user_actions_handler';
import { NEW_COMMENT_ID } from './constants';
const MyEuiFlexGroup = styled(EuiFlexGroup)`
margin-bottom: 8px;
`;
const MyEuiCommentList = styled(EuiCommentList)`
${({ theme }) => `
& .userAction__comment.outlined .euiCommentEvent {
outline: solid 5px ${theme.eui.euiColorVis1_behindText};
margin: 0.5em;
transition: 0.8s;
}
& .euiComment.isEdit {
& .euiCommentEvent {
border: none;
box-shadow: none;
}
& .euiCommentEvent__body {
padding: 0;
}
& .euiCommentEvent__header {
display: none;
}
}
& .comment-alert .euiCommentEvent {
background-color: ${theme.eui.euiColorLightestShade};
border: ${theme.eui.euiFlyoutBorder};
padding: ${theme.eui.paddingSizes.s};
border-radius: ${theme.eui.paddingSizes.xs};
}
& .comment-alert .euiCommentEvent__headerData {
flex-grow: 1;
}
& .comment-action.empty-comment .euiCommentEvent--regular {
box-shadow: none;
.euiCommentEvent__header {
padding: ${theme.eui.euiSizeM} ${theme.eui.paddingSizes.s};
border-bottom: 0;
}
}
`}
`;
export const UserActions = React.memo(
({
caseServices,
caseUserActions,
data: caseData,
fetchUserActions,
getRuleDetailsHref,
actionsNavigation,
isLoadingDescription,
isLoadingUserActions,
onRuleDetailsClick,
onShowAlertDetails,
onUpdateField,
renderInvestigateInTimelineActionComponent,
statusActionButton,
updateCase,
useFetchAlertData,
userCanCrud,
}: UserActionTreeProps) => {
const { detailName: caseId, subCaseId, commentId } = useCaseViewParams();
const [initLoading, setInitLoading] = useState(true);
const currentUser = useCurrentUser();
const [loadingAlertData, manualAlertsData] = useFetchAlertData(
getManualAlertIdsWithNoRuleId(caseData.comments)
);
const {
loadingCommentIds,
commentRefs,
selectedOutlineCommentId,
manageMarkdownEditIds,
handleManageMarkdownEditId,
handleOutlineComment,
handleSaveComment,
handleManageQuote,
handleUpdate,
} = useUserActionsHandler({ fetchUserActions, updateCase });
const MarkdownNewComment = useMemo(
() => (
<AddComment
id={NEW_COMMENT_ID}
caseId={caseId}
userCanCrud={userCanCrud}
ref={(element) => (commentRefs.current[NEW_COMMENT_ID] = element)}
onCommentPosted={handleUpdate}
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_COMMENT_ID)}
showLoading={false}
statusActionButton={statusActionButton}
subCaseId={subCaseId}
/>
),
[
caseId,
userCanCrud,
handleUpdate,
handleManageMarkdownEditId,
statusActionButton,
subCaseId,
commentRefs,
]
);
useEffect(() => {
if (initLoading && !isLoadingUserActions && loadingCommentIds.length === 0) {
setInitLoading(false);
if (commentId != null) {
handleOutlineComment(commentId);
}
}
}, [commentId, initLoading, isLoadingUserActions, loadingCommentIds, handleOutlineComment]);
const descriptionCommentListObj: EuiCommentProps = useMemo(
() =>
getDescriptionUserAction({
caseData,
commentRefs,
manageMarkdownEditIds,
isLoadingDescription,
userCanCrud,
onUpdateField,
handleManageMarkdownEditId,
handleManageQuote,
}),
[
caseData,
commentRefs,
manageMarkdownEditIds,
isLoadingDescription,
userCanCrud,
onUpdateField,
handleManageMarkdownEditId,
handleManageQuote,
]
);
const userActions: EuiCommentProps[] = useMemo(
() =>
caseUserActions.reduce<EuiCommentProps[]>(
(comments, userAction, index) => {
if (!isUserActionTypeSupported(userAction.type)) {
return comments;
}
const builder = builderMap[userAction.type];
if (builder == null) {
return comments;
}
const userActionBuilder = builder({
caseData,
userAction,
caseServices,
comments: caseData.comments,
index,
userCanCrud,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
alertData: manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
});
return [...comments, ...userActionBuilder.build()];
},
[descriptionCommentListObj]
),
[
caseUserActions,
descriptionCommentListObj,
caseData,
caseServices,
userCanCrud,
commentRefs,
manageMarkdownEditIds,
selectedOutlineCommentId,
loadingCommentIds,
loadingAlertData,
manualAlertsData,
handleOutlineComment,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
onShowAlertDetails,
actionsNavigation,
getRuleDetailsHref,
onRuleDetailsClick,
]
);
const bottomActions = userCanCrud
? [
{
username: (
<UserActionUsername
username={currentUser?.username}
fullName={currentUser?.fullName}
/>
),
'data-test-subj': 'add-comment',
timelineIcon: (
<UserActionAvatar username={currentUser?.username} fullName={currentUser?.fullName} />
),
className: 'isEdit',
children: MarkdownNewComment,
},
]
: [];
const comments = [...userActions, ...bottomActions];
return (
<>
<MyEuiCommentList comments={comments} data-test-subj="user-actions" />
{(isLoadingUserActions || loadingCommentIds.includes(NEW_COMMENT_ID)) && (
<MyEuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner data-test-subj="user-actions-loading" size="l" />
</EuiFlexItem>
</MyEuiFlexGroup>
)}
</>
);
}
);
UserActions.displayName = 'UserActions';

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { UserActionMarkdown } from './user_action_markdown';
import { UserActionMarkdown } from './markdown_form';
import { TestProviders } from '../../common/mock';
import { waitFor } from '@testing-library/react';
const onChangeEditable = jest.fn();

View file

@ -0,0 +1,126 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/eui';
import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
import styled from 'styled-components';
import * as i18n from '../case_view/translations';
import { Form, useForm, UseField } from '../../common/shared_imports';
import { schema, Content } from './schema';
import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor';
export const ContentWrapper = styled.div`
padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`};
`;
interface UserActionMarkdownProps {
content: string;
id: string;
isEditable: boolean;
onChangeEditable: (id: string) => void;
onSaveContent: (content: string) => void;
}
export interface UserActionMarkdownRefObject {
setComment: (newComment: string) => void;
}
const UserActionMarkdownComponent = forwardRef<
UserActionMarkdownRefObject,
UserActionMarkdownProps
>(({ id, content, isEditable, onChangeEditable, onSaveContent }, ref) => {
const editorRef = useRef();
const initialState = { content };
const { form } = useForm<Content>({
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
const fieldName = 'content';
const { setFieldValue, submit } = form;
const handleCancelAction = useCallback(() => {
onChangeEditable(id);
}, [id, onChangeEditable]);
const handleSaveAction = useCallback(async () => {
const { isValid, data } = await submit();
if (isValid && data.content !== content) {
onSaveContent(data.content);
}
onChangeEditable(id);
}, [content, id, onChangeEditable, onSaveContent, submit]);
const setComment = useCallback(
(newComment) => {
setFieldValue(fieldName, newComment);
},
[setFieldValue]
);
const EditorButtons = useMemo(
() => (
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="user-action-cancel-markdown"
size="s"
onClick={handleCancelAction}
iconType="cross"
>
{i18n.CANCEL}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="user-action-save-markdown"
color="success"
fill
iconType="save"
onClick={handleSaveAction}
size="s"
>
{i18n.SAVE}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
[handleCancelAction, handleSaveAction]
);
useImperativeHandle(ref, () => ({
setComment,
editor: editorRef.current,
}));
return isEditable ? (
<Form form={form} data-test-subj="user-action-markdown-form">
<UseField
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
ref: editorRef,
'aria-label': 'Cases markdown editor',
value: content,
id,
bottomRightContent: EditorButtons,
}}
/>
</Form>
) : (
<ContentWrapper data-test-subj="user-action-markdown">
<MarkdownRenderer>{content}</MarkdownRenderer>
</ContentWrapper>
);
});
UserActionMarkdownComponent.displayName = 'UserActionMarkdownComponent';
export const UserActionMarkdown = React.memo(UserActionMarkdownComponent);

View file

@ -0,0 +1,80 @@
/*
* 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 { Actions } from '../../../common/api';
import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
import { basicCase, basicPush, getUserAction } from '../../containers/mock';
import { UserActionBuilderArgs } from './types';
export const getMockBuilderArgs = (): UserActionBuilderArgs => {
const userAction = getUserAction('title', Actions.update);
const commentRefs = { current: {} };
const caseServices = {
'123': {
...basicPush,
firstPushIndex: 0,
lastPushIndex: 0,
commentsToUpdate: [],
hasDataToPush: true,
},
};
const alertData = {
'alert-id-1': {
_id: 'alert-id-1',
_index: 'alert-index-1',
signal: {
rule: {
id: ['rule-id-1'],
name: ['Awesome rule'],
false_positives: [],
},
},
kibana: {
alert: {
rule: {
uuid: ['rule-id-1'],
name: ['Awesome rule'],
false_positives: [],
parameters: {},
},
},
},
owner: SECURITY_SOLUTION_OWNER,
},
};
const getRuleDetailsHref = jest.fn().mockReturnValue('https://example.com');
const onRuleDetailsClick = jest.fn();
const onShowAlertDetails = jest.fn();
const handleManageMarkdownEditId = jest.fn();
const handleSaveComment = jest.fn();
const handleManageQuote = jest.fn();
const handleOutlineComment = jest.fn();
return {
userAction,
caseData: basicCase,
comments: basicCase.comments,
caseServices,
index: 0,
alertData,
userCanCrud: true,
commentRefs,
manageMarkdownEditIds: [],
selectedOutlineCommentId: '',
loadingCommentIds: [],
loadingAlertData: false,
getRuleDetailsHref,
onRuleDetailsClick,
onShowAlertDetails,
handleManageMarkdownEditId,
handleSaveComment,
handleManageQuote,
handleOutlineComment,
};
};

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionMoveToReference } from './user_action_move_to_reference';
import { UserActionMoveToReference } from './move_to_reference';
const outlineComment = jest.fn();
const props = {

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionPropertyActions } from './user_action_property_actions';
import { UserActionPropertyActions } from './property_actions';
jest.mock('../../common/lib/kibana');

View file

@ -0,0 +1,160 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions, NONE_CONNECTOR_ID } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createPushedUserActionBuilder } from './pushed';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createPushedUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
const caseServices = builderArgs.caseServices;
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly pushing for the first time', async () => {
const userAction = getUserAction('pushed', Actions.push_to_service);
const builder = createPushedUserActionBuilder({
...builderArgs,
userAction,
caseServices,
index: 0,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('pushed as new incident connector name')).toBeInTheDocument();
expect(screen.getByText('external title').closest('a')).toHaveAttribute(
'href',
'basicPush.com'
);
});
it('renders correctly when updating an external service', async () => {
const userAction = getUserAction('pushed', Actions.push_to_service);
const builder = createPushedUserActionBuilder({
...builderArgs,
userAction,
caseServices,
index: 1,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('updated incident connector name')).toBeInTheDocument();
});
it('renders the pushing indicators correctly', async () => {
const userAction = getUserAction('pushed', Actions.push_to_service);
const builder = createPushedUserActionBuilder({
...builderArgs,
userAction,
caseServices: {
...caseServices,
'123': {
...caseServices['123'],
lastPushIndex: 1,
},
},
index: 1,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('Already pushed to connector name incident')).toBeInTheDocument();
expect(screen.getByText('Requires update to connector name incident')).toBeInTheDocument();
});
it('shows only the already pushed indicator if has no data to push', async () => {
const userAction = getUserAction('pushed', Actions.push_to_service);
const builder = createPushedUserActionBuilder({
...builderArgs,
userAction,
caseServices: {
...caseServices,
'123': {
...caseServices['123'],
lastPushIndex: 1,
hasDataToPush: false,
},
},
index: 1,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('Already pushed to connector name incident')).toBeInTheDocument();
expect(
screen.queryByText('Requires update to connector name incident')
).not.toBeInTheDocument();
});
it('does not show the push information if the connector is none', async () => {
const userAction = getUserAction('pushed', Actions.push_to_service, {
payload: {
externalService: { connectorId: NONE_CONNECTOR_ID, connectorName: 'none connector' },
},
});
const builder = createPushedUserActionBuilder({
...builderArgs,
userAction,
caseServices: {
...caseServices,
'123': {
...caseServices['123'],
lastPushIndex: 1,
},
},
index: 1,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.queryByText('pushed as new incident none connector')).not.toBeInTheDocument();
expect(screen.queryByText('updated incident none connector')).not.toBeInTheDocument();
expect(screen.queryByText('Already pushed to connector name incident')).not.toBeInTheDocument();
expect(
screen.queryByText('Requires update to connector name incident')
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,148 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentProps, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui';
import { Actions, NONE_CONNECTOR_ID, PushedUserAction } from '../../../common/api';
import { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import * as i18n from './translations';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { CaseExternalService } from '../../containers/types';
const getPushInfo = (
caseServices: CaseServices,
externalService: CaseExternalService | undefined,
index: number
) =>
externalService != null && externalService.connectorId !== NONE_CONNECTOR_ID
? {
firstPush: caseServices[externalService.connectorId]?.firstPushIndex === index,
parsedConnectorId: externalService.connectorId,
parsedConnectorName: externalService.connectorName,
}
: {
firstPush: false,
parsedConnectorId: NONE_CONNECTOR_ID,
parsedConnectorName: NONE_CONNECTOR_ID,
};
const getLabelTitle = (action: UserActionResponse<PushedUserAction>, firstPush: boolean) => {
const externalService = action.payload.externalService;
return (
<EuiFlexGroup
alignItems="baseline"
gutterSize="xs"
data-test-subj="pushed-service-label-title"
responsive={false}
>
<EuiFlexItem data-test-subj="pushed-label">
{`${firstPush ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} ${
externalService?.connectorName
}`}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink data-test-subj="pushed-value" href={externalService?.externalUrl} target="_blank">
{externalService?.externalTitle}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const getFooters = ({
userAction,
caseServices,
connectorId,
connectorName,
index,
}: {
userAction: UserActionResponse<PushedUserAction>;
caseServices: CaseServices;
connectorId: string;
connectorName: string;
index: number;
}): EuiCommentProps[] => {
const showTopFooter =
userAction.action === Actions.push_to_service &&
index === caseServices[connectorId]?.lastPushIndex;
const showBottomFooter =
userAction.action === Actions.push_to_service &&
index === caseServices[connectorId]?.lastPushIndex &&
caseServices[connectorId].hasDataToPush;
let footers: EuiCommentProps[] = [];
if (showTopFooter) {
footers = [
...footers,
{
username: '',
type: 'update',
event: i18n.ALREADY_PUSHED_TO_SERVICE(`${connectorName}`),
timelineIcon: 'sortUp',
'data-test-subj': 'top-footer',
},
];
}
if (showBottomFooter) {
footers = [
...footers,
{
username: '',
type: 'update',
event: i18n.REQUIRED_UPDATE_TO_SERVICE(`${connectorName}`),
timelineIcon: 'sortDown',
'data-test-subj': 'bottom-footer',
},
];
}
return footers;
};
export const createPushedUserActionBuilder: UserActionBuilder = ({
userAction,
caseServices,
index,
handleOutlineComment,
}) => ({
build: () => {
const pushedUserAction = userAction as UserActionResponse<PushedUserAction>;
const { firstPush, parsedConnectorId, parsedConnectorName } = getPushInfo(
caseServices,
pushedUserAction.payload.externalService,
index
);
if (parsedConnectorId === NONE_CONNECTOR_ID) {
return [];
}
const footers = getFooters({
userAction: pushedUserAction,
caseServices,
connectorId: parsedConnectorId,
connectorName: parsedConnectorName,
index,
});
const label = getLabelTitle(pushedUserAction, firstPush);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'dot',
});
return [...commonBuilder.build(), ...footers];
},
});

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions, CaseStatuses } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createStatusUserActionBuilder } from './status';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createStatusUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
const tests = [
[CaseStatuses.open, 'Open'],
[CaseStatuses['in-progress'], 'In progress'],
[CaseStatuses.closed, 'Closed'],
];
beforeEach(() => {
jest.clearAllMocks();
});
it.each(tests)('renders correctly when changed to %s status', async (status, label) => {
const userAction = getUserAction('status', Actions.update, { payload: { status } });
const builder = createStatusUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('marked case as')).toBeInTheDocument();
expect(screen.getByText(label)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { CaseStatuses, StatusUserAction } from '../../../common/api';
import { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import { Status, statuses } from '../status';
import * as i18n from './translations';
const isStatusValid = (status: string): status is CaseStatuses =>
Object.prototype.hasOwnProperty.call(statuses, status);
const getLabelTitle = (userAction: UserActionResponse<StatusUserAction>) => {
const status = userAction.payload.status ?? '';
if (isStatusValid(status)) {
return (
<EuiFlexGroup
gutterSize="s"
alignItems="center"
data-test-subj={`${userAction.actionId}-user-action-status-title`}
responsive={false}
>
<EuiFlexItem grow={false}>{i18n.MARKED_CASE_AS}</EuiFlexItem>
<EuiFlexItem grow={false}>
<Status type={status} />
</EuiFlexItem>
</EuiFlexGroup>
);
}
return <></>;
};
export const createStatusUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
const statusUserAction = userAction as UserActionResponse<StatusUserAction>;
const label = getLabelTitle(statusUserAction);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'folderClosed',
});
return commonBuilder.build();
},
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createTagsUserActionBuilder } from './tags';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createTagsUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly when adding a tag', async () => {
const userAction = getUserAction('tags', Actions.add);
const builder = createTagsUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('added tags')).toBeInTheDocument();
expect(screen.getByText('a tag')).toBeInTheDocument();
});
it('renders correctly when deleting a tag', async () => {
const userAction = getUserAction('tags', Actions.delete);
const builder = createTagsUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText('removed tags')).toBeInTheDocument();
expect(screen.getByText('a tag')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Actions, TagsUserAction } from '../../../common/api';
import { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import { Tags } from '../tag_list/tags';
import * as i18n from './translations';
const getLabelTitle = (userAction: UserActionResponse<TagsUserAction>) => {
const tags = userAction.payload.tags ?? [];
return (
<EuiFlexGroup alignItems="baseline" gutterSize="xs" component="span" responsive={false}>
<EuiFlexItem data-test-subj="ua-tags-label" grow={false}>
{userAction.action === Actions.add && i18n.ADDED_FIELD}
{userAction.action === Actions.delete && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<Tags tags={tags} gutterSize="xs" />
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const createTagsUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
const tagsUserAction = userAction as UserActionResponse<TagsUserAction>;
const label = getLabelTitle(tagsUserAction);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'tag',
});
return commonBuilder.build();
},
});

View file

@ -8,7 +8,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { TestProviders } from '../../common/mock';
import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionTimestamp } from './timestamp';
jest.mock('@kbn/i18n-react', () => {
const originalModule = jest.requireActual('@kbn/i18n-react');

View file

@ -9,7 +9,7 @@ import React, { memo } from 'react';
import { EuiTextColor } from '@elastic/eui';
import { FormattedRelative } from '@kbn/i18n-react';
import { LocalizedDateTooltip } from '../../components/localized_date_tooltip';
import { LocalizedDateTooltip } from '../localized_date_tooltip';
import * as i18n from './translations';
interface UserActionAvatarProps {

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCommentList } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { Actions } from '../../../common/api';
import { getUserAction } from '../../containers/mock';
import { TestProviders } from '../../common/mock';
import { createTitleUserActionBuilder } from './title';
import { getMockBuilderArgs } from './mock';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
describe('createTitleUserActionBuilder ', () => {
const builderArgs = getMockBuilderArgs();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly', async () => {
const userAction = getUserAction('title', Actions.update);
// @ts-ignore no need to pass all the arguments
const builder = createTitleUserActionBuilder({
...builderArgs,
userAction,
});
const createdUserAction = builder.build();
render(
<TestProviders>
<EuiCommentList comments={createdUserAction} />
</TestProviders>
);
expect(screen.getByText(`changed case name to "a title"`)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,34 @@
/*
* 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 { TitleUserAction } from '../../../common/api';
import { UserActionBuilder, UserActionResponse } from './types';
import { createCommonUpdateUserActionBuilder } from './common';
import * as i18n from './translations';
const getLabelTitle = (userAction: UserActionResponse<TitleUserAction>) =>
`${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${
userAction.payload.title
}"`;
export const createTitleUserActionBuilder: UserActionBuilder = ({
userAction,
handleOutlineComment,
}) => ({
build: () => {
const titleUserAction = userAction as UserActionResponse<TitleUserAction>;
const label = getLabelTitle(titleUserAction);
const commonBuilder = createCommonUpdateUserActionBuilder({
userAction,
handleOutlineComment,
label,
icon: 'dot',
});
return commonBuilder.build();
},
});

View file

@ -0,0 +1,91 @@
/*
* 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 { EuiCommentProps } from '@elastic/eui';
import { SnakeToCamelCase } from '../../../common/types';
import { ActionTypes, UserActionWithResponse } from '../../../common/api';
import { Case, CaseUserActions, Ecs, Comment } from '../../containers/types';
import { CaseServices } from '../../containers/use_get_case_user_actions';
import { AddCommentRefObject } from '../add_comment';
import { UserActionMarkdownRefObject } from './markdown_form';
import { CasesNavigation } from '../links';
import { UNSUPPORTED_ACTION_TYPES } from './constants';
import type { OnUpdateFields } from '../case_view/types';
export interface UserActionTreeProps {
caseServices: CaseServices;
caseUserActions: CaseUserActions[];
data: Case;
fetchUserActions: () => void;
getRuleDetailsHref?: RuleDetailsNavigation['href'];
actionsNavigation?: ActionsNavigation;
isLoadingDescription: boolean;
isLoadingUserActions: boolean;
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
onShowAlertDetails: (alertId: string, index: string) => void;
onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void;
renderInvestigateInTimelineActionComponent?: (alertIds: string[]) => JSX.Element;
statusActionButton: JSX.Element | null;
updateCase: (newCase: Case) => void;
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
userCanCrud: boolean;
}
type UnsupportedUserActionTypes = typeof UNSUPPORTED_ACTION_TYPES[number];
export type SupportedUserActionTypes = keyof Omit<typeof ActionTypes, UnsupportedUserActionTypes>;
export interface UserActionBuilderArgs {
caseData: Case;
userAction: CaseUserActions;
caseServices: CaseServices;
comments: Comment[];
index: number;
userCanCrud: boolean;
commentRefs: React.MutableRefObject<
Record<string, AddCommentRefObject | UserActionMarkdownRefObject | null | undefined>
>;
manageMarkdownEditIds: string[];
selectedOutlineCommentId: string;
loadingCommentIds: string[];
loadingAlertData: boolean;
alertData: Record<string, Ecs>;
handleOutlineComment: (id: string) => void;
handleManageMarkdownEditId: (id: string) => void;
handleSaveComment: ({ id, version }: { id: string; version: string }, content: string) => void;
handleManageQuote: (quote: string) => void;
onShowAlertDetails: (alertId: string, index: string) => void;
actionsNavigation?: ActionsNavigation;
getRuleDetailsHref?: RuleDetailsNavigation['href'];
onRuleDetailsClick?: RuleDetailsNavigation['onClick'];
}
export type UserActionResponse<T> = SnakeToCamelCase<UserActionWithResponse<T>>;
export type UserActionBuilder = (args: UserActionBuilderArgs) => {
build: () => EuiCommentProps[];
};
export type UserActionBuilderMap = Record<SupportedUserActionTypes, UserActionBuilder>;
export type RuleDetailsNavigation = CasesNavigation<string | null | undefined, 'configurable'>;
export type ActionsNavigation = CasesNavigation<string, 'configurable'>;
interface Signal {
rule: {
id: string;
name: string;
to: string;
from: string;
};
}
export interface Alert {
_id: string;
_index: string;
'@timestamp': string;
signal: Signal;
[key: string]: unknown;
}

View file

@ -0,0 +1,171 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { basicCase } from '../../containers/mock';
import { useUpdateComment } from '../../containers/use_update_comment';
import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment';
import { NEW_COMMENT_ID } from './constants';
import {
useUserActionsHandler,
UseUserActionsHandlerArgs,
UseUserActionsHandler,
} from './use_user_actions_handler';
jest.mock('../../common/lib/kibana');
jest.mock('../../common/navigation/hooks');
jest.mock('../markdown_editor/plugins/lens/use_lens_draft_comment');
jest.mock('../../containers/use_update_comment');
const useUpdateCommentMock = useUpdateComment as jest.Mock;
const useLensDraftCommentMock = useLensDraftComment as jest.Mock;
const patchComment = jest.fn();
const clearDraftComment = jest.fn();
const openLensModal = jest.fn();
describe('useUserActionsHandler', () => {
const fetchUserActions = jest.fn();
const updateCase = jest.fn();
beforeAll(() => {
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(() => {
jest.clearAllMocks();
useUpdateCommentMock.mockReturnValue({
isLoadingIds: [],
patchComment,
});
useLensDraftCommentMock.mockReturnValue({
clearDraftComment,
openLensModal,
draftComment: null,
hasIncomingLensState: false,
});
});
it('init', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
expect(result.current).toEqual({
loadingCommentIds: [],
selectedOutlineCommentId: '',
manageMarkdownEditIds: [],
commentRefs: result.current.commentRefs,
handleManageMarkdownEditId: result.current.handleManageMarkdownEditId,
handleOutlineComment: result.current.handleOutlineComment,
handleSaveComment: result.current.handleSaveComment,
handleManageQuote: result.current.handleManageQuote,
handleUpdate: result.current.handleUpdate,
});
});
it('should saves a comment', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
result.current.handleSaveComment({ id: 'test-id', version: 'test-version' }, 'a comment');
expect(patchComment).toHaveBeenCalledWith({
caseId: 'basic-case-id',
commentId: 'test-id',
commentUpdate: 'a comment',
fetchUserActions,
subCaseId: undefined,
updateCase,
version: 'test-version',
});
});
it('should update a case', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
result.current.handleUpdate(basicCase);
expect(fetchUserActions).toHaveBeenCalled();
expect(updateCase).toHaveBeenCalledWith(basicCase);
});
it('should handle markdown edit', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
act(() => {
result.current.handleManageMarkdownEditId('test-id');
});
expect(clearDraftComment).toHaveBeenCalled();
expect(result.current.manageMarkdownEditIds).toEqual(['test-id']);
});
it('should remove id from the markdown edit ids', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
act(() => {
result.current.handleManageMarkdownEditId('test-id');
});
expect(result.current.manageMarkdownEditIds).toEqual(['test-id']);
act(() => {
result.current.handleManageMarkdownEditId('test-id');
});
expect(result.current.manageMarkdownEditIds).toEqual([]);
});
it('should outline a comment', async () => {
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
act(() => {
result.current.handleOutlineComment('test-id');
});
expect(result.current.selectedOutlineCommentId).toBe('test-id');
act(() => {
jest.runAllTimers();
});
expect(result.current.selectedOutlineCommentId).toBe('');
});
it('should quote', async () => {
const addQuote = jest.fn();
const { result } = renderHook<UseUserActionsHandlerArgs, UseUserActionsHandler>(() =>
useUserActionsHandler({ fetchUserActions, updateCase })
);
result.current.commentRefs.current[NEW_COMMENT_ID] = {
addQuote,
setComment: jest.fn(),
};
act(() => {
result.current.handleManageQuote('my quote');
});
expect(addQuote).toHaveBeenCalledWith('my quote');
expect(result.current.selectedOutlineCommentId).toBe('add-comment');
});
});

View file

@ -0,0 +1,167 @@
/*
* 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 { useCallback, useEffect, useRef, useState } from 'react';
import { useCaseViewParams } from '../../common/navigation';
import { Case } from '../../containers/types';
import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment';
import { useUpdateComment } from '../../containers/use_update_comment';
import { AddCommentRefObject } from '../add_comment';
import { UserActionMarkdownRefObject } from './markdown_form';
import { UserActionBuilderArgs, UserActionTreeProps } from './types';
import { NEW_COMMENT_ID } from './constants';
export type UseUserActionsHandlerArgs = Pick<
UserActionTreeProps,
'fetchUserActions' | 'updateCase'
>;
export type UseUserActionsHandler = Pick<
UserActionBuilderArgs,
| 'loadingCommentIds'
| 'selectedOutlineCommentId'
| 'manageMarkdownEditIds'
| 'commentRefs'
| 'handleManageMarkdownEditId'
| 'handleOutlineComment'
| 'handleSaveComment'
| 'handleManageQuote'
> & { handleUpdate: (updatedCase: Case) => void };
const isAddCommentRef = (
ref: AddCommentRefObject | UserActionMarkdownRefObject | null | undefined
): ref is AddCommentRefObject => {
const commentRef = ref as AddCommentRefObject;
return commentRef?.addQuote != null;
};
export const useUserActionsHandler = ({
fetchUserActions,
updateCase,
}: UseUserActionsHandlerArgs): UseUserActionsHandler => {
const { detailName: caseId, subCaseId } = useCaseViewParams();
const { clearDraftComment, draftComment, hasIncomingLensState, openLensModal } =
useLensDraftComment();
const handlerTimeoutId = useRef(0);
const { isLoadingIds, patchComment } = useUpdateComment();
const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState('');
const [manageMarkdownEditIds, setManageMarkdownEditIds] = useState<string[]>([]);
const commentRefs = useRef<
Record<string, AddCommentRefObject | UserActionMarkdownRefObject | undefined | null>
>({});
const handleManageMarkdownEditId = useCallback(
(id: string) => {
clearDraftComment();
setManageMarkdownEditIds((prevManageMarkdownEditIds) =>
!prevManageMarkdownEditIds.includes(id)
? prevManageMarkdownEditIds.concat(id)
: prevManageMarkdownEditIds.filter((myId) => id !== myId)
);
},
[clearDraftComment]
);
const handleSaveComment = useCallback(
({ id, version }: { id: string; version: string }, content: string) => {
patchComment({
caseId,
commentId: id,
commentUpdate: content,
fetchUserActions,
version,
updateCase,
subCaseId,
});
},
[caseId, fetchUserActions, patchComment, subCaseId, updateCase]
);
const handleOutlineComment = useCallback(
(id: string) => {
const moveToTarget = document.getElementById(`${id}-permLink`);
if (moveToTarget != null) {
const yOffset = -120;
const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({
top: y,
behavior: 'smooth',
});
if (id === 'add-comment') {
moveToTarget.getElementsByTagName('textarea')[0].focus();
}
}
window.clearTimeout(handlerTimeoutId.current);
setSelectedOutlineCommentId(id);
handlerTimeoutId.current = window.setTimeout(() => {
setSelectedOutlineCommentId('');
window.clearTimeout(handlerTimeoutId.current);
}, 2400);
},
[handlerTimeoutId]
);
const handleManageQuote = useCallback(
(quote: string) => {
const ref = commentRefs?.current[NEW_COMMENT_ID];
if (isAddCommentRef(ref)) {
ref.addQuote(quote);
}
handleOutlineComment('add-comment');
},
[handleOutlineComment]
);
const handleUpdate = useCallback(
(newCase: Case) => {
updateCase(newCase);
fetchUserActions();
},
[fetchUserActions, updateCase]
);
useEffect(() => {
if (draftComment?.commentId) {
setManageMarkdownEditIds((prevManageMarkdownEditIds) => {
if (
NEW_COMMENT_ID !== draftComment?.commentId &&
!prevManageMarkdownEditIds.includes(draftComment?.commentId)
) {
return [draftComment?.commentId];
}
return prevManageMarkdownEditIds;
});
const ref = commentRefs?.current?.[draftComment.commentId];
if (isAddCommentRef(ref) && ref.editor?.textarea) {
ref.setComment(draftComment.comment);
if (hasIncomingLensState) {
openLensModal({ editorRef: ref.editor });
} else {
clearDraftComment();
}
}
}
}, [clearDraftComment, draftComment, hasIncomingLensState, openLensModal]);
return {
loadingCommentIds: isLoadingIds,
selectedOutlineCommentId,
manageMarkdownEditIds,
commentRefs,
handleManageMarkdownEditId,
handleOutlineComment,
handleSaveComment,
handleManageQuote,
handleUpdate,
};
};

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { UserActionUsername } from './user_action_username';
import { UserActionUsername } from './username';
const props = {
username: 'elastic',

View file

@ -32,6 +32,7 @@ import {
import { SECURITY_SOLUTION_OWNER } from '../../common/constants';
import { UseGetCasesState, DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases';
import { SnakeToCamelCase } from '../../common/types';
import { covertToSnakeCase } from './utils';
export { connectorsMock } from './configure/mock';
export const basicCaseId = 'basic-case-id';
@ -258,6 +259,8 @@ export const basicCommentPatch: Comment = {
updatedAt: basicUpdatedAt,
updatedBy: {
username: 'elastic',
email: 'elastic@elastic.co',
fullName: 'Elastic',
},
};
@ -428,55 +431,138 @@ export const allCasesSnake: CasesFindResponse = {
...casesStatusSnake,
};
const basicActionSnake = {
created_at: basicCreatedAt,
created_by: elasticUserSnake,
case_id: basicCaseId,
comment_id: null,
owner: SECURITY_SOLUTION_OWNER,
};
export const getUserActionSnake = (
type: UserActionTypes,
action: UserAction,
payload?: Record<string, unknown>
): CaseUserActionResponse => {
const isPushToService = type === ActionTypes.pushed;
return {
...basicActionSnake,
action_id: `${type}-${action}`,
type,
action,
comment_id: type === 'comment' ? basicCommentId : null,
payload: isPushToService ? { externalService: basicPushSnake } : payload ?? basicAction.payload,
} as unknown as CaseUserActionResponse;
};
export const caseUserActionsSnake: CaseUserActionsResponse = [
getUserActionSnake('description', Actions.create, { description: 'a desc' }),
getUserActionSnake('comment', Actions.create, {
comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER },
}),
getUserActionSnake('description', Actions.update, { description: 'a desc updated' }),
];
export const getUserAction = (
type: UserActionTypes,
action: UserAction,
overrides?: Record<string, unknown>
): CaseUserActions => {
return {
const commonProperties = {
...basicAction,
actionId: `${type}-${action}`,
type,
action,
commentId: type === 'comment' ? basicCommentId : null,
payload: type === 'pushed' ? { externalService: basicPush } : basicAction.payload,
...overrides,
} as CaseUserActions;
};
const externalService = {
connectorId: pushConnectorId,
connectorName: 'connector name',
externalId: 'external_id',
externalTitle: 'external title',
externalUrl: 'basicPush.com',
pushedAt: basicUpdatedAt,
pushedBy: elasticUser,
};
switch (type) {
case ActionTypes.comment:
return {
...commonProperties,
type: ActionTypes.comment,
payload: {
comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER },
},
commentId: basicCommentId,
...overrides,
};
case ActionTypes.connector:
return {
...commonProperties,
type: ActionTypes.connector,
payload: {
connector: { ...getJiraConnector() },
},
...overrides,
};
case ActionTypes.create_case:
return {
...commonProperties,
type: ActionTypes.create_case,
payload: {
description: 'a desc',
connector: { ...getJiraConnector() },
status: CaseStatuses.open,
title: 'a title',
tags: ['a tag'],
settings: { syncAlerts: true },
owner: SECURITY_SOLUTION_OWNER,
},
...overrides,
};
case ActionTypes.delete_case:
return {
...commonProperties,
type: ActionTypes.delete_case,
payload: {},
...overrides,
};
case ActionTypes.description:
return {
...commonProperties,
type: ActionTypes.description,
payload: { description: 'a desc' },
...overrides,
};
case ActionTypes.pushed:
return {
...commonProperties,
type: ActionTypes.pushed,
payload: {
externalService,
},
...overrides,
};
case ActionTypes.settings:
return {
...commonProperties,
type: ActionTypes.settings,
payload: { settings: { syncAlerts: true } },
...overrides,
};
case ActionTypes.status:
return {
...commonProperties,
type: ActionTypes.status,
payload: { status: CaseStatuses.open },
...overrides,
};
case ActionTypes.tags:
return {
...commonProperties,
type: ActionTypes.tags,
payload: { tags: ['a tag'] },
...overrides,
};
case ActionTypes.title:
return {
...commonProperties,
type: ActionTypes.title,
payload: { title: 'a title' },
...overrides,
};
default:
return {
...commonProperties,
...overrides,
} as CaseUserActions;
}
};
export const getUserActionSnake = (
type: UserActionTypes,
action: UserAction,
overrides?: Record<string, unknown>
): CaseUserActionResponse => {
return {
...covertToSnakeCase(getUserAction(type, action, overrides)),
} as unknown as CaseUserActionResponse;
};
export const caseUserActionsSnake: CaseUserActionsResponse = [
getUserActionSnake('description', Actions.create),
getUserActionSnake('comment', Actions.create),
getUserActionSnake('description', Actions.update),
];
export const getJiraConnector = (overrides?: Partial<CaseConnector>): CaseConnector => {
return {
id: '123',
@ -492,9 +578,8 @@ export const jiraFields = { fields: { issueType: '10006', priority: null, parent
export const getAlertUserAction = (): SnakeToCamelCase<
UserActionWithResponse<CommentUserAction>
> => ({
...basicAction,
...getUserAction(ActionTypes.comment, Actions.create),
actionId: 'alert-action-id',
action: Actions.create,
commentId: 'alert-comment-id',
type: ActionTypes.comment,
payload: {
@ -514,10 +599,9 @@ export const getAlertUserAction = (): SnakeToCamelCase<
export const getHostIsolationUserAction = (): SnakeToCamelCase<
UserActionWithResponse<CommentUserAction>
> => ({
...basicAction,
...getUserAction(ActionTypes.comment, Actions.create),
actionId: 'isolate-action-id',
type: ActionTypes.comment,
action: Actions.create,
commentId: 'isolate-comment-id',
payload: {
comment: {
@ -530,13 +614,9 @@ export const getHostIsolationUserAction = (): SnakeToCamelCase<
});
export const caseUserActions: CaseUserActions[] = [
getUserAction('description', Actions.create, { payload: { description: 'a desc' } }),
getUserAction('comment', Actions.create, {
payload: {
comment: { comment: 'a comment', type: CommentType.user, owner: SECURITY_SOLUTION_OWNER },
},
}),
getUserAction('description', Actions.update, { payload: { description: 'a desc updated' } }),
getUserAction('description', Actions.create),
getUserAction('comment', Actions.create),
getUserAction('description', Actions.update),
];
// components tests

View file

@ -6,7 +6,7 @@
*/
import { set } from '@elastic/safer-lodash-set';
import { camelCase, isArray, isObject } from 'lodash';
import { camelCase, isArray, isObject, transform, snakeCase } from 'lodash';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
@ -40,6 +40,12 @@ import * as i18n from './translations';
export const getTypedPayload = <T>(a: unknown): T => a as T;
export const covertToSnakeCase = (obj: Record<string, unknown>) =>
transform(obj, (acc: Record<string, unknown>, value, key, target) => {
const camelKey = Array.isArray(target) ? key : snakeCase(key);
acc[camelKey] = isObject(value) ? covertToSnakeCase(value as Record<string, unknown>) : value;
});
export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] =>
arrayOfSnakes.reduce((acc: unknown[], value) => {
if (isArray(value)) {
@ -51,8 +57,8 @@ export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] =>
}
}, []);
export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U =>
Object.entries(snakeCase).reduce((acc, [key, value]) => {
export const convertToCamelCase = <T, U extends {}>(obj: T): U =>
Object.entries(obj).reduce((acc, [key, value]) => {
if (isArray(value)) {
set(acc, camelCase(key), convertArrayToCamelCase(value));
} else if (isObject(value)) {
@ -64,7 +70,7 @@ export const convertToCamelCase = <T, U extends {}>(snakeCase: T): U =>
}, {} as U);
export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): AllCases => ({
cases: snakeCases.cases.map((snakeCase) => convertToCamelCase<CaseResponse, Case>(snakeCase)),
cases: snakeCases.cases.map((theCase) => convertToCamelCase<CaseResponse, Case>(theCase)),
countOpenCases: snakeCases.count_open_cases,
countInProgressCases: snakeCases.count_in_progress_cases,
countClosedCases: snakeCases.count_closed_cases,