mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
12ff04e0c2
commit
c673681b56
22 changed files with 2941 additions and 122 deletions
|
@ -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');
|
||||
|
|
|
@ -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))`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -9,3 +9,7 @@ export interface Timeline {
|
|||
description: string;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface TimelineWithId extends Timeline {
|
||||
id: string;
|
||||
}
|
||||
|
|
|
@ -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"]';
|
||||
|
|
|
@ -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"]';
|
||||
|
||||
|
|
|
@ -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"]';
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}, []);
|
||||
|
|
|
@ -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,
|
||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue