mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Cases] Refactor UI user actions (#121962)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
6956d9c1fe
commit
5a77714c4b
72 changed files with 2877 additions and 1620 deletions
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -68,7 +68,7 @@ describe('CreateCaseForm', () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
useGetTagsMock.mockReturnValue({ tags: ['test'] });
|
||||
useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock });
|
||||
useCaseConfigureMock.mockImplementation(() => useCaseConfigureResponse);
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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',
|
|
@ -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);
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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} />
|
|
@ -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,
|
||||
};
|
|
@ -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';
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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 {
|
|
@ -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;
|
|
@ -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',
|
|
@ -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;
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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';
|
|
@ -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');
|
|
@ -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;
|
|
@ -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>;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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']);
|
|
@ -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) => {
|
|
@ -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(() => {
|
275
x-pack/plugins/cases/public/components/user_actions/index.tsx
Normal file
275
x-pack/plugins/cases/public/components/user_actions/index.tsx
Normal 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';
|
|
@ -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();
|
|
@ -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);
|
80
x-pack/plugins/cases/public/components/user_actions/mock.ts
Normal file
80
x-pack/plugins/cases/public/components/user_actions/mock.ts
Normal 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,
|
||||
};
|
||||
};
|
|
@ -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 = {
|
|
@ -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');
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
148
x-pack/plugins/cases/public/components/user_actions/pushed.tsx
Normal file
148
x-pack/plugins/cases/public/components/user_actions/pushed.tsx
Normal 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];
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
49
x-pack/plugins/cases/public/components/user_actions/tags.tsx
Normal file
49
x-pack/plugins/cases/public/components/user_actions/tags.tsx
Normal 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();
|
||||
},
|
||||
});
|
|
@ -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');
|
|
@ -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 {
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
},
|
||||
});
|
91
x-pack/plugins/cases/public/components/user_actions/types.ts
Normal file
91
x-pack/plugins/cases/public/components/user_actions/types.ts
Normal 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;
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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',
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue