From b9ebbc62a809530cca7b5f03312642630b38efe9 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 15 Sep 2020 23:48:12 +0300 Subject: [PATCH] [Security Solutions][Cases - Timeline] Fix bug when adding a timeline to a case (#76967) Co-authored-by: Gloria Hornero Co-authored-by: Elastic Machine --- .../cypress/integration/cases.spec.ts | 4 +- .../timeline_attach_to_case.spec.ts | 76 + .../security_solution/cypress/objects/case.ts | 7 +- .../cypress/objects/timeline.ts | 4 + .../cypress/screens/all_cases.ts | 6 + .../cypress/screens/create_new_case.ts | 3 +- .../cypress/screens/timeline.ts | 11 + .../cypress/tasks/create_new_case.ts | 12 + .../security_solution/cypress/tasks/login.ts | 9 + .../cypress/tasks/timeline.ts | 28 + .../public/cases/components/__mock__/form.ts | 7 + .../components/add_comment/index.test.tsx | 9 +- .../cases/components/add_comment/index.tsx | 26 +- .../cases/components/create/index.test.tsx | 15 +- .../public/cases/components/create/index.tsx | 22 +- .../user_action_tree/index.test.tsx | 69 +- .../user_action_tree/user_action_markdown.tsx | 21 +- .../insert_timeline_popover/index.test.tsx | 55 +- .../insert_timeline_popover/index.tsx | 23 +- .../use_insert_timeline.tsx | 40 +- .../case_and_timeline/data.json.gz | Bin 0 -> 3687 bytes .../case_and_timeline/mappings.json | 2616 +++++++++++++++++ 22 files changed, 2941 insertions(+), 122 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts create mode 100644 x-pack/test/security_solution_cypress/es_archives/case_and_timeline/data.json.gz create mode 100644 x-pack/test/security_solution_cypress/es_archives/case_and_timeline/mappings.json diff --git a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts index 9438c28f05fef..6194d6892d799 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases.spec.ts @@ -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'); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts new file mode 100644 index 0000000000000..6af4d174b9583 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_attach_to_case.spec.ts @@ -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))` + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index 12d3f925169af..084df31a604a3 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -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'; diff --git a/x-pack/plugins/security_solution/cypress/objects/timeline.ts b/x-pack/plugins/security_solution/cypress/objects/timeline.ts index 060a1376b46ce..ff7e80e5661ad 100644 --- a/x-pack/plugins/security_solution/cypress/objects/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/objects/timeline.ts @@ -9,3 +9,7 @@ export interface Timeline { description: string; query: string; } + +export interface TimelineWithId extends Timeline { + id: string; +} diff --git a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts index 4fa6b69eea7c3..dc0e764744f84 100644 --- a/x-pack/plugins/security_solution/cypress/screens/all_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/all_cases.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts index 6e2beb78fff19..9431c054d96a4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_case.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index fd41cd63fc090..bcb64fc947feb 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -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"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts index b2cde23a8dce2..1d5d240c5c53d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_case.ts @@ -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'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/login.ts b/x-pack/plugins/security_solution/cypress/tasks/login.ts index ca23a1defd4f5..65f821ec5bfb7 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/login.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/login.ts @@ -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'); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 6fb8bb5e29ae5..cd8b197fc4dec 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -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'); }; diff --git a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts index 96c1217577ff2..87f8f46affb52 100644 --- a/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts +++ b/x-pack/plugins/security_solution/public/cases/components/__mock__/form.ts @@ -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; diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx index f697ce443f2c5..a800bd690f710 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.test.tsx @@ -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); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx index 87bd7bb247056..ef13c87a92dbb 100644 --- a/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/add_comment/index.tsx @@ -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( ({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => { const { isLoading, postComment } = usePostComment(caseId); + const { form } = useForm({ defaultValue: initialCommentValue, options: { stripEmptyFields: false }, schema, }); - const { getFormData, setFieldValue, reset, submit } = form; - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - 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 && }
{ 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, diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx index 31e6da4269ead..3c3cc95218b03 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.tsx @@ -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( - 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(() => { { })); 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( @@ -94,10 +97,16 @@ describe('UserActionTree ', () => { ); + + 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( @@ -119,16 +129,22 @@ describe('UserActionTree ', () => { ); + + 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( @@ -136,6 +152,11 @@ describe('UserActionTree ', () => { ); + + 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( @@ -161,6 +183,11 @@ describe('UserActionTree ', () => { ); + + 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 ', () => { ); - 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( @@ -324,6 +368,11 @@ describe('UserActionTree ', () => { ); + + await act(async () => { + wrapper.update(); + }); + expect( wrapper.find(`[data-test-subj="comment-create-action"]`).first().prop('idToOutline') ).toEqual(commentId); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx index da081fea5eac0..ac2ad179ec60c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -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( - 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 ? ( { - 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(); - 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(); - expect(mockDispatch).toHaveBeenCalledTimes(0); - expect(onTimelineChange).toHaveBeenCalledTimes(0); + it('it renders', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="insert-timeline-popover"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx index 0adf767308269..11ad54321da88 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/index.tsx @@ -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 = ({ 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); }, []); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx index c3bcd1c0ebe51..55c0709bd5543 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/insert_timeline_popover/use_insert_timeline.tsx @@ -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 = (form: FormHook, fieldName: string) => { +export const useInsertTimeline = (value: string, onChange: (newValue: string) => void) => { const basePath = window.location.origin + useBasePath(); + const dispatch = useDispatch(); const [cursorPosition, setCursorPosition] = useState({ 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, diff --git a/x-pack/test/security_solution_cypress/es_archives/case_and_timeline/data.json.gz b/x-pack/test/security_solution_cypress/es_archives/case_and_timeline/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..5838d18e1c7dd2c5907cc5f5ef9f55d4cfe2a8d8 GIT binary patch literal 3687 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