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 ? ( (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({ @@ -22,9 +20,7 @@ export const useInsertTimeline = (form: FormHook, fieldNa end: 0, }); - const insertTimeline = useSelector((state: State) => { - return timelineSelectors.selectInsertTimeline(state); - }); + const insertTimeline = useSelector(timelineSelectors.selectInsertTimeline, shallowEqual); const handleOnTimelineChange = useCallback( (title: string, id: string | null, graphEventId?: string) => { @@ -32,18 +28,17 @@ export const useInsertTimeline = (form: FormHook, fieldNa !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) => { @@ -53,8 +48,7 @@ export const useInsertTimeline = (form: FormHook, fieldNa // 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(() => { - const currentValue = form.getFormData()[fieldName]; - if (insertTimeline != null && currentValue != null) { + if (insertTimeline != null && value != null) { dispatch(timelineActions.showTimeline({ id: insertTimeline.timelineId, show: false })); handleOnTimelineChange( insertTimeline.timelineTitle, @@ -63,7 +57,7 @@ export const useInsertTimeline = (form: FormHook, fieldNa ); dispatch(setInsertTimeline(null)); } - }, [insertTimeline, dispatch, form, handleOnTimelineChange, fieldName]); + }, [insertTimeline, dispatch, handleOnTimelineChange, value]); return { cursorPosition,