[7.x] [Security Solutions][Cases - Timeline] Fix bug when adding a timeline to a case (#76967) (#77547)

Co-authored-by: Gloria Hornero <snootchie.boochies@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Gloria Hornero <snootchie.boochies@gmail.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2020-09-16 08:44:11 +03:00 committed by GitHub
parent 12ff04e0c2
commit c673681b56
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 2941 additions and 122 deletions

View file

@ -40,7 +40,7 @@ import { TIMELINE_DESCRIPTION, TIMELINE_QUERY, TIMELINE_TITLE } from '../screens
import { goToCaseDetails, goToCreateNewCase } from '../tasks/all_cases';
import { openCaseTimeline } from '../tasks/case_details';
import { backToCases, createNewCase } from '../tasks/create_new_case';
import { backToCases, createNewCaseWithTimeline } from '../tasks/create_new_case';
import { loginAndWaitForPageWithoutDateRange } from '../tasks/login';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
@ -58,7 +58,7 @@ describe('Cases', () => {
it('Creates a new case with timeline and opens the timeline', () => {
loginAndWaitForPageWithoutDateRange(CASES_URL);
goToCreateNewCase();
createNewCase(case1);
createNewCaseWithTimeline(case1);
backToCases();
cy.get(ALL_CASES_PAGE_TITLE).should('have.text', 'Cases Beta');

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { loginAndWaitForTimeline } from '../tasks/login';
import {
attachTimelineToNewCase,
attachTimelineToExistingCase,
addNewCase,
selectCase,
} from '../tasks/timeline';
import { DESCRIPTION_INPUT } from '../screens/create_new_case';
import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver';
import { caseTimeline, TIMELINE_CASE_ID } from '../objects/case';
describe('attach timeline to case', () => {
beforeEach(() => {
loginAndWaitForTimeline(caseTimeline.id);
});
context('without cases created', () => {
before(() => {
esArchiverLoad('timeline');
});
after(() => {
esArchiverUnload('timeline');
});
it('attach timeline to a new case', () => {
attachTimelineToNewCase();
cy.location('origin').then((origin) => {
cy.get(DESCRIPTION_INPUT).should(
'have.text',
`[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
);
});
});
it('attach timeline to an existing case with no case', () => {
attachTimelineToExistingCase();
addNewCase();
cy.location('origin').then((origin) => {
cy.get(DESCRIPTION_INPUT).should(
'have.text',
`[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
);
});
});
});
context('with cases created', () => {
before(() => {
esArchiverLoad('case_and_timeline');
});
after(() => {
esArchiverUnload('case_and_timeline');
});
it('attach timeline to an existing case', () => {
attachTimelineToExistingCase();
selectCase(TIMELINE_CASE_ID);
cy.location('origin').then((origin) => {
cy.get(DESCRIPTION_INPUT).should(
'have.text',
`[${caseTimeline.title}](${origin}/app/security/timelines?timeline=(id:'${caseTimeline.id}',isOpen:!t))`
);
});
});
});
});

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Timeline } from './timeline';
import { Timeline, TimelineWithId } from './timeline';
export interface TestCase {
name: string;
@ -21,10 +21,11 @@ export interface Connector {
password: string;
}
const caseTimeline: Timeline = {
export const caseTimeline: TimelineWithId = {
title: 'SIEM test',
description: 'description',
query: 'host.name:*',
id: '0162c130-78be-11ea-9718-118a926974a4',
};
export const case1: TestCase = {
@ -41,3 +42,5 @@ export const serviceNowConnector: Connector = {
username: 'Username Name',
password: 'password',
};
export const TIMELINE_CASE_ID = '68248e00-f689-11ea-9ab2-59238b522856';

View file

@ -9,3 +9,7 @@ export interface Timeline {
description: string;
query: string;
}
export interface TimelineWithId extends Timeline {
id: string;
}

View file

@ -4,6 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ALL_CASES_CASE = (id: string) => {
return `[data-test-subj="cases-table-row-${id}"]`;
};
export const ALL_CASES_CLOSE_ACTION = '[data-test-subj="action-close"]';
export const ALL_CASES_CLOSED_CASES_COUNT = '[data-test-subj="closed-case-count"]';
@ -14,6 +18,8 @@ export const ALL_CASES_COMMENTS_COUNT = '[data-test-subj="case-table-column-comm
export const ALL_CASES_CREATE_NEW_CASE_BTN = '[data-test-subj="createNewCaseBtn"]';
export const ALL_CASES_CREATE_NEW_CASE_TABLE_BTN = '[data-test-subj="cases-table-add-case"]';
export const ALL_CASES_DELETE_ACTION = '[data-test-subj="action-delete"]';
export const ALL_CASES_NAME = '[data-test-subj="case-details-link"]';

View file

@ -6,8 +6,7 @@
export const BACK_TO_CASES_BTN = '[data-test-subj="backToCases"]';
export const DESCRIPTION_INPUT =
'[data-test-subj="caseDescription"] [data-test-subj="textAreaInput"]';
export const DESCRIPTION_INPUT = '[data-test-subj="textAreaInput"]';
export const INSERT_TIMELINE_BTN = '[data-test-subj="insert-timeline-button"]';

View file

@ -4,8 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/
export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-case"]';
export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON =
'[data-test-subj="attach-timeline-existing-case"]';
export const BULK_ACTIONS = '[data-test-subj="utility-bar-action-button"]';
export const CASE = (id: string) => {
return `[data-test-subj="cases-table-row-${id}"]`;
};
export const CLOSE_TIMELINE_BTN = '[data-test-subj="close-timeline"]';
export const CREATE_NEW_TIMELINE = '[data-test-subj="timeline-new"]';
@ -25,6 +34,8 @@ export const ID_FIELD = '[data-test-subj="timeline"] [data-test-subj="field-name
export const ID_TOGGLE_FIELD = '[data-test-subj="toggle-field-_id"]';
export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]';
export const PIN_EVENT = '[data-test-subj="pin"]';
export const PROVIDER_BADGE = '[data-test-subj="providerBadge"]';

View file

@ -29,6 +29,18 @@ export const createNewCase = (newCase: TestCase) => {
});
cy.get(DESCRIPTION_INPUT).type(`${newCase.description} `, { force: true });
cy.get(SUBMIT_BTN).click({ force: true });
cy.get(LOADING_SPINNER).should('exist');
cy.get(LOADING_SPINNER).should('not.exist');
};
export const createNewCaseWithTimeline = (newCase: TestCase) => {
cy.get(TITLE_INPUT).type(newCase.name, { force: true });
newCase.tags.forEach((tag) => {
cy.get(TAGS_INPUT).type(`${tag}{enter}`, { force: true });
});
cy.get(DESCRIPTION_INPUT).type(`${newCase.description} `, { force: true });
cy.get(INSERT_TIMELINE_BTN).click({ force: true });
cy.get(TIMELINE_SEARCHBOX).type(`${newCase.timeline.title}{enter}`);
cy.get(TIMELINE).should('be.visible');

View file

@ -5,6 +5,7 @@
*/
import * as yaml from 'js-yaml';
import { TIMELINE_FLYOUT_BODY } from '../screens/timeline';
/**
* Credentials in the `kibana.dev.yml` config file will be used to authenticate
@ -143,3 +144,11 @@ export const loginAndWaitForPageWithoutDateRange = (url: string) => {
cy.visit(url);
cy.get('[data-test-subj="headerGlobalNav"]', { timeout: 120000 });
};
export const loginAndWaitForTimeline = (timelineId: string) => {
login();
cy.viewport('macbook-15');
cy.visit(`/app/security/timelines?timeline=(id:'${timelineId}',isOpen:!t)`);
cy.get('[data-test-subj="headerGlobalNav"]');
cy.get(TIMELINE_FLYOUT_BODY).should('be.visible');
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ALL_CASES_CREATE_NEW_CASE_TABLE_BTN } from '../screens/all_cases';
import {
BULK_ACTIONS,
CLOSE_TIMELINE_BTN,
@ -28,6 +29,10 @@ import {
TOGGLE_TIMELINE_EXPAND_EVENT,
REMOVE_COLUMN,
RESET_FIELDS,
ATTACH_TIMELINE_TO_NEW_CASE_ICON,
OPEN_TIMELINE_ICON,
ATTACH_TIMELINE_TO_EXISTING_CASE_ICON,
CASE,
} from '../screens/timeline';
import { drag, drop } from '../tasks/common';
@ -44,6 +49,20 @@ export const addNameToTimeline = (name: string) => {
cy.get(TIMELINE_TITLE).should('have.attr', 'value', name);
};
export const addNewCase = () => {
cy.get(ALL_CASES_CREATE_NEW_CASE_TABLE_BTN).click();
};
export const attachTimelineToNewCase = () => {
cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
cy.get(ATTACH_TIMELINE_TO_NEW_CASE_ICON).click({ force: true });
};
export const attachTimelineToExistingCase = () => {
cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
cy.get(ATTACH_TIMELINE_TO_EXISTING_CASE_ICON).click({ force: true });
};
export const checkIdToggleField = () => {
cy.get(ID_HEADER_FIELD).should('not.exist');
@ -85,6 +104,11 @@ export const openTimelineInspectButton = () => {
cy.get(TIMELINE_INSPECT_BUTTON).trigger('click', { force: true });
};
export const openTimelineFromSettings = () => {
cy.get(TIMELINE_SETTINGS_ICON).click({ force: true });
cy.get(OPEN_TIMELINE_ICON).click({ force: true });
};
export const openTimelineSettings = () => {
cy.get(TIMELINE_SETTINGS_ICON).trigger('click', { force: true });
};
@ -132,6 +156,10 @@ export const resetFields = () => {
cy.get(RESET_FIELDS).click({ force: true });
};
export const selectCase = (caseId: string) => {
cy.get(CASE(caseId)).click();
};
export const waitForTimelinesPanelToBeLoaded = () => {
cy.get(TIMELINES_TABLE).should('exist');
};

View file

@ -4,9 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
export const mockFormHook = {
isSubmitted: false,
isSubmitting: false,
@ -41,3 +47,4 @@ export const getFormMock = (sampleData: any) => ({
});
export const useFormMock = useForm as jest.Mock;
export const useFormDataMock = useFormData as jest.Mock;

View file

@ -15,6 +15,7 @@ import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { usePostComment } from '../../containers/use_post_comment';
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
// we don't have the types for waitFor just yet, so using "as waitFor" until when we do
import { wait as waitFor } from '@testing-library/react';
@ -23,10 +24,15 @@ jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_post_comment');
export const useFormMock = useForm as jest.Mock;
const useFormMock = useForm as jest.Mock;
const useFormDataMock = useFormData as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const usePostCommentMock = usePostComment as jest.Mock;
@ -73,6 +79,7 @@ describe('AddComment ', () => {
useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline);
usePostCommentMock.mockImplementation(() => defaultPostCommment);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ comment: sampleData.comment }]);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
});

View file

@ -14,7 +14,7 @@ import { Case } from '../../containers/types';
import { MarkdownEditorForm } from '../../../common/components/markdown_editor/form';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
import { Form, useForm, UseField } from '../../../shared_imports';
import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
import * as i18n from './translations';
import { schema } from './schema';
@ -46,23 +46,31 @@ export const AddComment = React.memo(
forwardRef<AddCommentRefObject, AddCommentProps>(
({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => {
const { isLoading, postComment } = usePostComment(caseId);
const { form } = useForm<CommentRequest>({
defaultValue: initialCommentValue,
options: { stripEmptyFields: false },
schema,
});
const { getFormData, setFieldValue, reset, submit } = form;
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CommentRequest>(
form,
'comment'
const fieldName = 'comment';
const { setFieldValue, reset, submit } = form;
const [{ comment }] = useFormData({ form, watch: [fieldName] });
const onCommentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
setFieldValue,
]);
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
comment,
onCommentChange
);
const addQuote = useCallback(
(quote) => {
const { comment } = getFormData();
setFieldValue('comment', `${comment}${comment.length > 0 ? '\n\n' : ''}${quote}`);
setFieldValue(fieldName, `${comment}${comment.length > 0 ? '\n\n' : ''}${quote}`);
},
[getFormData, setFieldValue]
[comment, setFieldValue]
);
useImperativeHandle(ref, () => ({
@ -87,7 +95,7 @@ export const AddComment = React.memo(
{isLoading && showLoading && <MySpinner data-test-subj="loading-spinner" size="xl" />}
<Form form={form}>
<UseField
path="comment"
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
idAria: 'caseComment',

View file

@ -16,16 +16,23 @@ import { useInsertTimeline } from '../../../timelines/components/timeline/insert
import { usePostCase } from '../../containers/use_post_case';
import { useGetTags } from '../../containers/use_get_tags';
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_post_case');
import { useForm } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form';
import { useFormData } from '../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data';
// we don't have the types for waitFor just yet, so using "as waitFor" until when we do
import { wait as waitFor } from '@testing-library/react';
jest.mock('../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline');
jest.mock('../../containers/use_post_case');
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form'
);
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_form_data'
);
jest.mock('../../containers/use_get_tags');
jest.mock(
'../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib/components/form_data_provider',
@ -35,7 +42,8 @@ jest.mock(
})
);
export const useFormMock = useForm as jest.Mock;
const useFormMock = useForm as jest.Mock;
const useFormDataMock = useFormData as jest.Mock;
const useInsertTimelineMock = useInsertTimeline as jest.Mock;
const usePostCaseMock = usePostCase as jest.Mock;
@ -83,6 +91,7 @@ describe('Create case', () => {
useInsertTimelineMock.mockImplementation(() => defaultInsertTimeline);
usePostCaseMock.mockImplementation(() => defaultPostCase);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ description: sampleData.description }]);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
(useGetTags as jest.Mock).mockImplementation(() => ({
tags: sampleTags,

View file

@ -24,6 +24,7 @@ import {
useForm,
UseField,
FormDataProvider,
useFormData,
} from '../../../shared_imports';
import { usePostCase } from '../../containers/use_post_case';
import { schema } from './schema';
@ -69,13 +70,18 @@ export const Create = React.memo(() => {
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const fieldName = 'description';
const { submit, setFieldValue } = form;
const [{ description }] = useFormData({ form, watch: [fieldName] });
const { tags: tagOptions } = useGetTags();
const [options, setOptions] = useState(
tagOptions.map((label) => ({
label,
}))
);
useEffect(
() =>
setOptions(
@ -85,10 +91,16 @@ export const Create = React.memo(() => {
),
[tagOptions]
);
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<CasePostRequest>(
form,
'description'
const onDescriptionChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
setFieldValue,
]);
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
description,
onDescriptionChange
);
const handleTimelineClick = useTimelineClick();
const onSubmit = useCallback(async () => {
@ -141,7 +153,7 @@ export const Create = React.memo(() => {
</Container>
<ContainerBig>
<UseField
path="description"
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
dataTestSubj: 'caseDescription',

View file

@ -8,7 +8,7 @@ import React from 'react';
import { mount } from 'enzyme';
import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router';
import { getFormMock, useFormMock } from '../__mock__/form';
import { getFormMock, useFormMock, useFormDataMock } from '../__mock__/form';
import { useUpdateComment } from '../../containers/use_update_comment';
import { basicCase, basicPush, getUserAction } from '../../containers/mock';
import { UserActionTree } from '.';
@ -49,6 +49,7 @@ describe('UserActionTree ', () => {
}));
const formHookMock = getFormMock(sampleData);
useFormMock.mockImplementation(() => ({ form: formHookMock }));
useFormDataMock.mockImplementation(() => [{ content: sampleData.content, comment: '' }]);
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
});
@ -69,7 +70,8 @@ describe('UserActionTree ', () => {
defaultProps.data.createdBy.username
);
});
it('Renders service now update line with top and bottom when push is required', () => {
it('Renders service now update line with top and bottom when push is required', async () => {
const ourActions = [
getUserAction(['pushed'], 'push-to-service'),
getUserAction(['comment'], 'update'),
@ -87,6 +89,7 @@ describe('UserActionTree ', () => {
},
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -94,10 +97,16 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeTruthy();
});
it('Renders service now update line with top only when push is up to date', () => {
it('Renders service now update line with top only when push is up to date', async () => {
const ourActions = [getUserAction(['pushed'], 'push-to-service')];
const props = {
...defaultProps,
@ -112,6 +121,7 @@ describe('UserActionTree ', () => {
},
},
};
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -119,16 +129,22 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(wrapper.find(`[data-test-subj="show-top-footer"]`).exists()).toBeTruthy();
expect(wrapper.find(`[data-test-subj="show-bottom-footer"]`).exists()).toBeFalsy();
});
it('Outlines comment when update move to link is clicked', () => {
it('Outlines comment when update move to link is clicked', async () => {
const ourActions = [getUserAction(['comment'], 'create'), getUserAction(['comment'], 'update')];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -136,6 +152,11 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(
wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline')
).toEqual('');
@ -148,12 +169,13 @@ describe('UserActionTree ', () => {
).toEqual(ourActions[0].commentId);
});
it('Switches to markdown when edit is clicked and back to panel when canceled', () => {
it('Switches to markdown when edit is clicked and back to panel when canceled', async () => {
const ourActions = [getUserAction(['comment'], 'create')];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
const wrapper = mount(
<TestProviders>
<Router history={mockHistory}>
@ -161,6 +183,11 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(
wrapper
.find(
@ -168,14 +195,17 @@ describe('UserActionTree ', () => {
)
.exists()
).toEqual(false);
wrapper
.find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-ellipses"]`)
.first()
.simulate('click');
wrapper
.find(`[data-test-subj="comment-create-action"] [data-test-subj="property-actions-pencil"]`)
.first()
.simulate('click');
expect(
wrapper
.find(
@ -183,12 +213,14 @@ describe('UserActionTree ', () => {
)
.exists()
).toEqual(true);
wrapper
.find(
`[data-test-subj="user-action-${props.data.comments[0].id}"] [data-test-subj="user-action-cancel-markdown"]`
)
.first()
.simulate('click');
expect(
wrapper
.find(
@ -299,23 +331,35 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`)
.first()
.simulate('click');
await act(async () => {
await waitFor(() => {
wrapper
.find(
`[data-test-subj="description-action"] [data-test-subj="property-actions-ellipses"]`
)
.first()
.simulate('click');
wrapper.update();
});
});
wrapper
.find(`[data-test-subj="description-action"] [data-test-subj="property-actions-quote"]`)
.first()
.simulate('click');
expect(setFieldValue).toBeCalledWith('comment', `> ${props.data.description} \n`);
});
it('Outlines comment when url param is provided', () => {
it('Outlines comment when url param is provided', async () => {
const commentId = 'neat-comment-id';
const ourActions = [getUserAction(['comment'], 'create')];
const props = {
...defaultProps,
caseUserActions: ourActions,
};
jest.spyOn(routeData, 'useParams').mockReturnValue({ commentId });
const wrapper = mount(
<TestProviders>
@ -324,6 +368,11 @@ describe('UserActionTree ', () => {
</Router>
</TestProviders>
);
await act(async () => {
wrapper.update();
});
expect(
wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline')
).toEqual(commentId);

View file

@ -10,7 +10,7 @@ import styled from 'styled-components';
import * as i18n from '../case_view/translations';
import { Markdown } from '../../../common/components/markdown';
import { Form, useForm, UseField } from '../../../shared_imports';
import { Form, useForm, UseField, useFormData } from '../../../shared_imports';
import { schema, Content } from './schema';
import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover';
import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline';
@ -41,11 +41,20 @@ export const UserActionMarkdown = ({
options: { stripEmptyFields: false },
schema,
});
const { submit } = form;
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline<Content>(
form,
'content'
const fieldName = 'content';
const { submit, setFieldValue } = form;
const [{ content: contentFormValue }] = useFormData({ form, watch: [fieldName] });
const onContentChange = useCallback((newValue) => setFieldValue(fieldName, newValue), [
setFieldValue,
]);
const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline(
contentFormValue,
onContentChange
);
const handleCancelAction = useCallback(() => {
onChangeEditable(id);
}, [id, onChangeEditable]);
@ -93,7 +102,7 @@ export const UserActionMarkdown = ({
return isEditable ? (
<Form form={form} data-test-subj="user-action-markdown-form">
<UseField
path="content"
path={fieldName}
component={MarkdownEditorForm}
componentProps={{
bottomRightContent: renderButtons({

View file

@ -6,64 +6,17 @@
import React from 'react';
import { mount } from 'enzyme';
/* eslint-disable @kbn/eslint/module_migration */
import routeData from 'react-router';
/* eslint-enable @kbn/eslint/module_migration */
import { InsertTimelinePopoverComponent } from '.';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const reactRedux = jest.requireActual('react-redux');
return {
...reactRedux,
useDispatch: () => mockDispatch,
useSelector: jest
.fn()
.mockReturnValueOnce({
timelineId: 'timeline-id',
timelineSavedObjectId: '34578-3497-5893-47589-34759',
timelineTitle: 'Timeline title',
})
.mockReturnValue(null),
};
});
const mockLocation = {
pathname: '/apath',
hash: '',
search: '',
state: '',
};
const onTimelineChange = jest.fn();
const defaultProps = {
const props = {
isDisabled: false,
onTimelineChange,
};
describe('Insert timeline popover ', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should insert a timeline when passed in the router state', () => {
mount(<InsertTimelinePopoverComponent {...defaultProps} />);
expect(mockDispatch.mock.calls[0][0]).toEqual({
payload: { id: 'timeline-id', show: false },
type: 'x-pack/security_solution/local/timeline/SHOW_TIMELINE',
});
expect(onTimelineChange).toBeCalledWith(
'Timeline title',
'34578-3497-5893-47589-34759',
undefined
);
expect(mockDispatch.mock.calls[1][0]).toEqual({
payload: null,
type: 'x-pack/security_solution/local/timeline/SET_INSERT_TIMELINE',
});
});
it('should do nothing when router state', () => {
jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation);
mount(<InsertTimelinePopoverComponent {...defaultProps} />);
expect(mockDispatch).toHaveBeenCalledTimes(0);
expect(onTimelineChange).toHaveBeenCalledTimes(0);
it('it renders', () => {
const wrapper = mount(<InsertTimelinePopoverComponent {...props} />);
expect(wrapper.find('[data-test-subj="insert-timeline-popover"]').exists()).toBeTruthy();
});
});

View file

@ -5,16 +5,12 @@
*/
import { EuiButtonIcon, EuiPopover, EuiSelectableOption, EuiToolTip } from '@elastic/eui';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { OpenTimelineResult } from '../../open_timeline/types';
import { SelectableTimeline } from '../selectable_timeline';
import * as i18n from '../translations';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
import { TimelineType } from '../../../../../common/types/timeline';
import { State } from '../../../../common/store';
import { setInsertTimeline } from '../../../store/timeline/actions';
interface InsertTimelinePopoverProps {
isDisabled: boolean;
@ -33,25 +29,8 @@ export const InsertTimelinePopoverComponent: React.FC<Props> = ({
hideUntitled = false,
onTimelineChange,
}) => {
const dispatch = useDispatch();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const insertTimeline = useSelector((state: State) => {
return timelineSelectors.selectInsertTimeline(state);
});
useEffect(() => {
if (insertTimeline != null) {
dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false }));
onTimelineChange(
insertTimeline.timelineTitle,
insertTimeline.timelineSavedObjectId,
insertTimeline.graphEventId
);
dispatch(setInsertTimeline(null));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertTimeline, dispatch]);
const handleClosePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);

View file

@ -5,38 +5,60 @@
*/
import { isEmpty } from 'lodash/fp';
import { useCallback, useState } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { useBasePath } from '../../../../common/lib/kibana';
import { CursorPosition } from '../../../../common/components/markdown_editor';
import { FormData, FormHook } from '../../../../shared_imports';
import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline';
import { setInsertTimeline } from '../../../store/timeline/actions';
export const useInsertTimeline = <T extends FormData>(form: FormHook<T>, fieldName: string) => {
export const useInsertTimeline = (value: string, onChange: (newValue: string) => void) => {
const basePath = window.location.origin + useBasePath();
const dispatch = useDispatch();
const [cursorPosition, setCursorPosition] = useState<CursorPosition>({
start: 0,
end: 0,
});
const insertTimeline = useSelector(timelineSelectors.selectInsertTimeline, shallowEqual);
const handleOnTimelineChange = useCallback(
(title: string, id: string | null, graphEventId?: string) => {
const builtLink = `${basePath}/app/security/timelines?timeline=(id:'${id}'${
!isEmpty(graphEventId) ? `,graphEventId:'${graphEventId}'` : ''
},isOpen:!t)`;
const currentValue = form.getFormData()[fieldName];
const newValue: string = [
currentValue.slice(0, cursorPosition.start),
value.slice(0, cursorPosition.start),
cursorPosition.start === cursorPosition.end
? `[${title}](${builtLink})`
: `[${currentValue.slice(cursorPosition.start, cursorPosition.end)}](${builtLink})`,
currentValue.slice(cursorPosition.end),
: `[${value.slice(cursorPosition.start, cursorPosition.end)}](${builtLink})`,
value.slice(cursorPosition.end),
].join('');
form.setFieldValue(fieldName, newValue);
onChange(newValue);
},
[basePath, cursorPosition, fieldName, form]
[value, onChange, basePath, cursorPosition]
);
const handleCursorChange = useCallback((cp: CursorPosition) => {
setCursorPosition(cp);
}, []);
// insertTimeline selector is defined to attached a timeline to a case outside of the case page.
// FYI, if you are in the case page we only use handleOnTimelineChange to attach a timeline to a case.
useEffect(() => {
if (insertTimeline != null && value != null) {
dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false }));
handleOnTimelineChange(
insertTimeline.timelineTitle,
insertTimeline.timelineSavedObjectId,
insertTimeline.graphEventId
);
dispatch(setInsertTimeline(null));
}
}, [insertTimeline, dispatch, handleOnTimelineChange, value]);
return {
cursorPosition,
handleCursorChange,

File diff suppressed because it is too large Load diff