From ca46f784e5185bbce503171e6432e960c94f2586 Mon Sep 17 00:00:00 2001 From: Philippe Oberti Date: Tue, 1 Oct 2024 15:44:41 -0500 Subject: [PATCH] [Security Solution][Notes] - fetch notes by saved object ids (#193930) --- .../output/kibana.serverless.staging.yaml | 10 + oas_docs/output/kibana.staging.yaml | 10 + .../timeline/get_notes/get_notes_route.gen.ts | 4 + .../get_notes/get_notes_route.schema.yaml | 10 + ...imeline_api_2023_10_31.bundled.schema.yaml | 10 + ...imeline_api_2023_10_31.bundled.schema.yaml | 10 + .../public/common/mock/global_state.ts | 2 + .../left/components/add_note.tsx | 206 ----------- .../attach_to_active_timeline.test.tsx | 122 +++++++ .../components/attach_to_active_timeline.tsx | 133 +++++++ .../left/components/notes_details.test.tsx | 148 +++++++- .../left/components/notes_details.tsx | 104 +++++- .../left/components/notes_list.test.tsx | 307 ---------------- .../left/components/notes_list.tsx | 211 ----------- .../left/components/test_ids.ts | 10 +- .../security_solution/public/notes/api/api.ts | 14 + .../components/add_note.test.tsx | 93 +---- .../public/notes/components/add_note.tsx | 148 ++++++++ .../components/delete_note_button.test.tsx | 120 +++++++ .../notes/components/delete_note_button.tsx | 85 +++++ .../notes/components/notes_list.test.tsx | 145 ++++++++ .../public/notes/components/notes_list.tsx | 100 ++++++ .../components/open_flyout_button.test.tsx | 62 ++++ .../notes/components/open_flyout_button.tsx | 74 ++++ .../components/open_timeline_button.test.tsx | 61 ++++ .../notes/components/open_timeline_button.tsx | 59 ++++ .../public/notes/components/test_ids.ts | 19 + .../public/notes/store/notes.slice.test.ts | 179 +++++++++- .../public/notes/store/notes.slice.ts | 68 +++- .../actions/save_timeline_button.test.tsx | 21 ++ .../modal/actions/save_timeline_button.tsx | 109 +++--- .../timelines/components/notes/old_notes.tsx | 202 +++++++++++ .../components/notes/participants.test.tsx | 77 ++++ .../components/notes/participants.tsx | 144 ++++++++ .../components/notes/save_timeline.test.tsx | 43 +++ .../components/notes/save_timeline.tsx | 65 ++++ .../timelines/components/notes/test_ids.ts | 15 + .../note_previews/index.test.tsx | 4 +- .../components/timeline/tabs/index.tsx | 44 ++- .../timeline/tabs/notes/index.test.tsx | 221 ++++++++++++ .../components/timeline/tabs/notes/index.tsx | 334 +++++++++--------- .../query_tab_unified_components.test.tsx | 3 +- .../timeline/routes/notes/get_notes.test.ts | 121 ++++++- .../lib/timeline/routes/notes/get_notes.ts | 30 ++ 44 files changed, 2889 insertions(+), 1068 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx rename x-pack/plugins/security_solution/public/{flyout/document_details/left => notes}/components/add_note.test.tsx (57%) create mode 100644 x-pack/plugins/security_solution/public/notes/components/add_note.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/notes_list.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx create mode 100644 x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts create mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx diff --git a/oas_docs/output/kibana.serverless.staging.yaml b/oas_docs/output/kibana.serverless.staging.yaml index 621efdc026eea..8fd6d8c7be347 100644 --- a/oas_docs/output/kibana.serverless.staging.yaml +++ b/oas_docs/output/kibana.serverless.staging.yaml @@ -14921,6 +14921,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/Security_Timeline_API_DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/Security_Timeline_API_SavedObjectIds' - in: query name: page schema: @@ -31674,6 +31678,12 @@ components: - threat_match - zeek type: string + Security_Timeline_API_SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string Security_Timeline_API_SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/oas_docs/output/kibana.staging.yaml b/oas_docs/output/kibana.staging.yaml index 79960c7287336..eb25c65d433fa 100644 --- a/oas_docs/output/kibana.staging.yaml +++ b/oas_docs/output/kibana.staging.yaml @@ -18351,6 +18351,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/Security_Timeline_API_DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/Security_Timeline_API_SavedObjectIds' - in: query name: page schema: @@ -39683,6 +39687,12 @@ components: - threat_match - zeek type: string + Security_Timeline_API_SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string Security_Timeline_API_SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts index 5851b95d4d606..c4c48022f6512 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.gen.ts @@ -21,6 +21,9 @@ import { Note } from '../model/components.gen'; export type DocumentIds = z.infer; export const DocumentIds = z.union([z.array(z.string()), z.string()]); +export type SavedObjectIds = z.infer; +export const SavedObjectIds = z.union([z.array(z.string()), z.string()]); + export type GetNotesResult = z.infer; export const GetNotesResult = z.object({ totalCount: z.number(), @@ -30,6 +33,7 @@ export const GetNotesResult = z.object({ export type GetNotesRequestQuery = z.infer; export const GetNotesRequestQuery = z.object({ documentIds: DocumentIds.optional(), + savedObjectIds: SavedObjectIds.optional(), page: z.string().nullable().optional(), perPage: z.string().nullable().optional(), search: z.string().nullable().optional(), diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml index 793eeac5e7c71..985e7728b7cc8 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.schema.yaml @@ -17,6 +17,10 @@ paths: in: query schema: $ref: '#/components/schemas/DocumentIds' + - name: savedObjectIds + in: query + schema: + $ref: '#/components/schemas/SavedObjectIds' - name: page in: query schema: @@ -65,6 +69,12 @@ components: items: type: string - type: string + SavedObjectIds: + oneOf: + - type: array + items: + type: string + - type: string GetNotesResult: type: object required: [totalCount, notes] diff --git a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index e36af57a2b3e9..68740efb388a4 100644 --- a/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/ess/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -63,6 +63,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/SavedObjectIds' - in: query name: page schema: @@ -1359,6 +1363,12 @@ components: - threat_match - zeek type: string + SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml index 818b83d7b85dd..cfcb36e2dee75 100644 --- a/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/plugins/security_solution/docs/openapi/serverless/security_solution_timeline_api_2023_10_31.bundled.schema.yaml @@ -63,6 +63,10 @@ paths: name: documentIds schema: $ref: '#/components/schemas/DocumentIds' + - in: query + name: savedObjectIds + schema: + $ref: '#/components/schemas/SavedObjectIds' - in: query name: page schema: @@ -1359,6 +1363,12 @@ components: - threat_match - zeek type: string + SavedObjectIds: + oneOf: + - items: + type: string + type: array + - type: string SavedObjectResolveAliasPurpose: enum: - savedObjectConversion diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index e3f9f19192a6b..16e1e7edf0eaa 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -527,12 +527,14 @@ export const mockGlobalState: State = { ids: ['1'], status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, createNote: null, deleteNotes: null, fetchNotes: null, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx deleted file mode 100644 index 5e4e390ac5077..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.tsx +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { - EuiButton, - EuiCheckbox, - EuiComment, - EuiCommentList, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiSpacer, - EuiToolTip, - useEuiTheme, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { useDispatch, useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; -import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineId } from '../../../../../common/types'; -import { timelineSelectors } from '../../../../timelines/store'; -import { - ADD_NOTE_BUTTON_TEST_ID, - ADD_NOTE_MARKDOWN_TEST_ID, - ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, -} from './test_ids'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import type { State } from '../../../../common/store'; -import { - createNote, - ReqStatus, - selectCreateNoteError, - selectCreateNoteStatus, -} from '../../../../notes/store/notes.slice'; -import { MarkdownEditor } from '../../../../common/components/markdown_editor'; - -const timelineCheckBoxId = 'xpack.securitySolution.flyout.left.notes.attachToTimelineCheckboxId'; - -export const MARKDOWN_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.markdownAriaLabel', - { - defaultMessage: 'Note', - } -); -export const ADD_NOTE_BUTTON = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.addNoteBtnLabel', - { - defaultMessage: 'Add note', - } -); -export const CREATE_NOTE_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.createNoteErrorLabel', - { - defaultMessage: 'Error create note', - } -); -export const ATTACH_TO_TIMELINE_CHECKBOX = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.attachToTimelineCheckboxLabel', - { - defaultMessage: 'Attach to active timeline', - } -); -export const ATTACH_TO_TIMELINE_INFO = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.attachToTimelineInfoLabel', - { - defaultMessage: 'The active timeline must be saved before a note can be associated with it', - } -); - -export interface AddNewNoteProps { - /** - * Id of the document - */ - eventId: string; -} - -/** - * Renders a markdown editor and an add button to create new notes. - * The checkbox is automatically checked if the flyout is opened from a timeline and that timeline is saved. It is disabled if the flyout is NOT opened from a timeline. - */ -export const AddNote = memo(({ eventId }: AddNewNoteProps) => { - const { telemetry } = useKibana().services; - const { euiTheme } = useEuiTheme(); - const dispatch = useDispatch(); - const { addError: addErrorToast } = useAppToasts(); - const [editorValue, setEditorValue] = useState(''); - const [isMarkdownInvalid, setIsMarkdownInvalid] = useState(false); - - const activeTimeline = useSelector((state: State) => - timelineSelectors.selectTimelineById(state, TimelineId.active) - ); - - // if the flyout is open from a timeline and that timeline is saved, we automatically check the checkbox to associate the note to it - const isTimelineFlyout = useWhichFlyout() === Flyouts.timeline; - - const [checked, setChecked] = useState(true); - const onCheckboxChange = useCallback( - (e: React.ChangeEvent) => setChecked(e.target.checked), - [] - ); - - const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - const createError = useSelector((state: State) => selectCreateNoteError(state)); - - const addNote = useCallback(() => { - dispatch( - createNote({ - note: { - timelineId: (checked && activeTimeline?.savedObjectId) || '', - eventId, - note: editorValue, - }, - }) - ); - telemetry.reportAddNoteFromExpandableFlyoutClicked({ - isRelatedToATimeline: checked && activeTimeline?.savedObjectId !== null, - }); - setEditorValue(''); - }, [activeTimeline?.savedObjectId, checked, dispatch, editorValue, eventId, telemetry]); - - // show a toast if the create note call fails - useEffect(() => { - if (createStatus === ReqStatus.Failed && createError) { - addErrorToast(null, { - title: CREATE_NOTE_ERROR, - }); - } - }, [addErrorToast, createError, createStatus]); - - const buttonDisabled = useMemo( - () => editorValue.trim().length === 0 || isMarkdownInvalid, - [editorValue, isMarkdownInvalid] - ); - - const initialCheckboxChecked = useMemo( - () => isTimelineFlyout && activeTimeline.savedObjectId != null, - [activeTimeline?.savedObjectId, isTimelineFlyout] - ); - - const checkBoxDisabled = useMemo( - () => !isTimelineFlyout || (isTimelineFlyout && activeTimeline?.savedObjectId == null), - [activeTimeline?.savedObjectId, isTimelineFlyout] - ); - - return ( - <> - - - - - - - - - <> - - {ATTACH_TO_TIMELINE_CHECKBOX} - - - - - } - disabled={checkBoxDisabled} - checked={initialCheckboxChecked && checked} - onChange={(e) => onCheckboxChange(e)} - /> - - - - - {ADD_NOTE_BUTTON} - - - - - ); -}); - -AddNote.displayName = 'AddNote'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx new file mode 100644 index 0000000000000..383750e05a006 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, + SAVE_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { AttachToActiveTimeline } from './attach_to_active_timeline'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { TimelineId } from '../../../../../common/types'; + +const mockSetAttachToTimeline = jest.fn(); + +describe('AttachToActiveTimeline', () => { + it('should render the component for an unsaved timeline', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, + }); + + const { getByTestId, getByText, queryByTestId } = render( + + + + ); + + expect(getByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); + + expect(getByText('Attach to timeline')).toBeInTheDocument(); + expect( + getByText('Before attaching a note to the timeline, you need to save the timeline first.') + ).toBeInTheDocument(); + + expect(getByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render the saved timeline texts in the callout', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, + }); + + const { getByTestId, getByText, queryByTestId } = render( + + + + ); + expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(getByText('Attach to timeline')).toBeInTheDocument(); + expect( + getByText('You can associate the newly created note to the active timeline.') + ).toBeInTheDocument(); + expect(getByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the callback when user click on the checkbox', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, + }); + + const { getByTestId } = render( + + + + ); + + const checkbox = getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID); + + checkbox.click(); + + expect(mockSetAttachToTimeline).toHaveBeenCalledWith(false); + + checkbox.click(); + + expect(mockSetAttachToTimeline).toHaveBeenCalledWith(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx new file mode 100644 index 0000000000000..278830da7e27f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/attach_to_active_timeline.tsx @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { EuiCallOut, EuiCheckbox, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { useSelector } from 'react-redux'; +import type { State } from '../../../../common/store'; +import { TimelineId } from '../../../../../common/types'; +import { SaveTimelineButton } from '../../../../timelines/components/modal/actions/save_timeline_button'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, + SAVE_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { timelineSelectors } from '../../../../timelines/store'; + +const timelineCheckBoxId = 'xpack.securitySolution.flyout.notes.attachToTimeline.checkboxId'; + +export const ATTACH_TO_TIMELINE_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutTitle', + { + defaultMessage: 'Attach to timeline', + } +); +export const SAVED_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutContent', + { + defaultMessage: 'You can associate the newly created note to the active timeline.', + } +); +export const UNSAVED_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.calloutContent', + { + defaultMessage: 'Before attaching a note to the timeline, you need to save the timeline first.', + } +); +export const ATTACH_TO_TIMELINE_CHECKBOX = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.attachToTimeline.checkboxLabel', + { + defaultMessage: 'Attach to active timeline', + } +); +export const SAVE_TIMELINE_BUTTON = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.savedTimelineButtonLabel', + { + defaultMessage: 'Save timeline', + } +); + +export interface AttachToActiveTimelineProps { + /** + * Let the parent component know if the user wants to attach the note to the timeline + */ + setAttachToTimeline: (checked: boolean) => void; + /** + * Disables the checkbox (if timeline is not saved) + */ + isCheckboxDisabled: boolean; +} + +/** + * Renders a callout and a checkbox to allow the user to attach a timeline id to a note. + * If the active timeline is saved, the UI renders a checkbox to allow the user to attach the note to the timeline. + * If the active timeline is not saved, the UI renders a button that allows the user to to save the timeline directly from the flyout. + */ +export const AttachToActiveTimeline = memo( + ({ setAttachToTimeline, isCheckboxDisabled }: AttachToActiveTimelineProps) => { + const [checked, setChecked] = useState(true); + + const timeline = useSelector((state: State) => + timelineSelectors.selectTimelineById(state, TimelineId.active) + ); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] + ); + + const onCheckboxChange = useCallback( + (e: React.ChangeEvent) => { + setChecked(e.target.checked); + setAttachToTimeline(e.target.checked); + }, + [setAttachToTimeline] + ); + + return ( + + + + + {isTimelineSaved ? SAVED_TIMELINE_CALLOUT_CONTENT : UNSAVED_TIMELINE_CALLOUT_CONTENT} + + + + {isTimelineSaved ? ( + onCheckboxChange(e)} + /> + ) : ( + + )} + + + + ); + } +); + +AttachToActiveTimeline.displayName = 'AttachToActiveTimeline'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx index dbe09d7e23599..9426d604bce57 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.test.tsx @@ -8,14 +8,34 @@ import { render } from '@testing-library/react'; import React from 'react'; import { DocumentDetailsContext } from '../../shared/context'; -import { TestProviders } from '../../../../common/mock'; -import { NotesDetails } from './notes_details'; -import { ADD_NOTE_BUTTON_TEST_ID } from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; +import { FETCH_NOTES_ERROR, NO_NOTES, NotesDetails } from './notes_details'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { + ADD_NOTE_BUTTON_TEST_ID, + NOTES_LOADING_TEST_ID, +} from '../../../../notes/components/test_ids'; +import { + ATTACH_TO_TIMELINE_CALLOUT_TEST_ID, + ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, +} from './test_ids'; +import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; +import { Flyouts } from '../../shared/constants/flyouts'; +import { TimelineId } from '../../../../../common/types'; +import { ReqStatus } from '../../../../notes'; + +jest.mock('../../shared/hooks/use_which_flyout'); jest.mock('../../../../common/components/user_privileges'); const useUserPrivilegesMock = useUserPrivileges as jest.Mock; +const mockAddError = jest.fn(); +jest.mock('../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + const mockDispatch = jest.fn(); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); @@ -28,6 +48,19 @@ jest.mock('react-redux', () => { const panelContextValue = { eventId: 'event id', } as unknown as DocumentDetailsContext; +const mockGlobalStateWithSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, +}; const renderNotesDetails = () => render( @@ -40,26 +73,121 @@ const renderNotesDetails = () => describe('NotesDetails', () => { beforeEach(() => { + jest.clearAllMocks(); useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, + kibanaSecuritySolutionsPrivileges: { crud: true }, }); + (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); }); it('should fetch notes for the document id', () => { - renderNotesDetails(); + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + render( + + + + + + ); + expect(mockDispatch).toHaveBeenCalled(); }); - it('should render an add note button', () => { - const { getByTestId } = renderNotesDetails(); - expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); + it('should render loading spinner if notes are being fetched', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should render no data message if no notes are present', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Succeeded, + }, + }, + }); + + const { getByText } = render( + + + + + + ); + + expect(getByText(NO_NOTES)).toBeInTheDocument(); }); - it('should not render an add note button for users without crud privileges', () => { + it('should render error toast if fetching notes fails', () => { + const store = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesByDocumentIds: ReqStatus.Failed, + }, + error: { + ...mockGlobalStateWithSavedTimeline.notes.error, + fetchNotesByDocumentIds: { type: 'http', status: 500 }, + }, + }, + }); + + render( + + + + + + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: FETCH_NOTES_ERROR, + }); + }); + + it('should not render the add note section for users without crud privileges', () => { useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, + kibanaSecuritySolutionsPrivileges: { crud: false }, }); + const { queryByTestId } = renderNotesDetails(); + expect(queryByTestId(ADD_NOTE_BUTTON_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not render the callout and attach to timeline checkbox if not timeline flyout', () => { + (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); + + const { getByTestId, queryByTestId } = renderNotesDetails(); + + expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx index b2b07110d1c4d..b3dfbd53416be 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_details.tsx @@ -5,36 +5,120 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; -import { EuiSpacer } from '@elastic/eui'; -import { AddNote } from './add_note'; -import { NotesList } from './notes_list'; -import { fetchNotesByDocumentIds } from '../../../../notes/store/notes.slice'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingElastic, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Flyouts } from '../../shared/constants/flyouts'; +import { timelineSelectors } from '../../../../timelines/store'; +import { TimelineId } from '../../../../../common/types'; +import { AttachToActiveTimeline } from './attach_to_active_timeline'; +import { AddNote } from '../../../../notes/components/add_note'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { NOTES_LOADING_TEST_ID } from '../../../../notes/components/test_ids'; +import { NotesList } from '../../../../notes/components/notes_list'; +import type { State } from '../../../../common/store'; +import type { Note } from '../../../../../common/api/timeline'; +import { + fetchNotesByDocumentIds, + ReqStatus, + selectFetchNotesByDocumentIdsError, + selectFetchNotesByDocumentIdsStatus, + selectSortedNotesByDocumentId, +} from '../../../../notes/store/notes.slice'; import { useDocumentDetailsContext } from '../../shared/context'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; +import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; + +export const FETCH_NOTES_ERROR = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.fetchNotesErrorLabel', + { + defaultMessage: 'Error fetching notes', + } +); +export const NO_NOTES = i18n.translate('xpack.securitySolution.flyout.left.notes.noNotesLabel', { + defaultMessage: 'No notes have been created for this document', +}); /** * List all the notes for a document id and allows to create new notes associated with that document. * Displayed in the document details expandable flyout left section. */ export const NotesDetails = memo(() => { + const { addError: addErrorToast } = useAppToasts(); const dispatch = useDispatch(); const { eventId } = useDocumentDetailsContext(); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const canCreateNotes = kibanaSecuritySolutionsPrivileges.crud; + // will drive the value we send to the AddNote component + // if true (timeline is saved and the user kept the checkbox checked) we'll send the timelineId to the AddNote component + // if false (timeline is not saved or the user unchecked the checkbox manually ) we'll send an empty string + const [attachToTimeline, setAttachToTimeline] = useState(true); + + // if the flyout is open from a timeline and that timeline is saved, we automatically check the checkbox to associate the note to it + const isTimelineFlyout = useWhichFlyout() === Flyouts.timeline; + + const timeline = useSelector((state: State) => + timelineSelectors.selectTimelineById(state, TimelineId.active) + ); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + + const notes: Note[] = useSelector((state: State) => + selectSortedNotesByDocumentId(state, { + documentId: eventId, + sort: { field: 'created', direction: 'asc' }, + }) + ); + const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state)); + const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state)); + + const fetchNotes = useCallback( + () => dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })), + [dispatch, eventId] + ); + useEffect(() => { - dispatch(fetchNotesByDocumentIds({ documentIds: [eventId] })); - }, [dispatch, eventId]); + fetchNotes(); + }, [fetchNotes]); + + // show a toast if the fetch notes call fails + useEffect(() => { + if (fetchStatus === ReqStatus.Failed && fetchError) { + addErrorToast(null, { + title: FETCH_NOTES_ERROR, + }); + } + }, [addErrorToast, fetchError, fetchStatus]); return ( <> - + {fetchStatus === ReqStatus.Loading && ( + + )} + {fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? ( + + +

{NO_NOTES}

+
+
+ ) : ( + + )} {canCreateNotes && ( <> - + + {isTimelineFlyout && ( + + )} + )} diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx deleted file mode 100644 index f7a0cf814415b..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ /dev/null @@ -1,307 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { render, within } from '@testing-library/react'; -import React from 'react'; -import { - ADD_NOTE_LOADING_TEST_ID, - DELETE_NOTE_BUTTON_TEST_ID, - NOTE_AVATAR_TEST_ID, - NOTES_COMMENT_TEST_ID, - NOTES_LOADING_TEST_ID, - OPEN_TIMELINE_BUTTON_TEST_ID, -} from './test_ids'; -import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; -import { DELETE_NOTE_ERROR, FETCH_NOTES_ERROR, NO_NOTES, NotesList } from './notes_list'; -import { ReqStatus } from '../../../../notes/store/notes.slice'; -import { useQueryTimelineById } from '../../../../timelines/components/open_timeline/helpers'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; - -jest.mock('../../../../common/components/user_privileges'); -const useUserPrivilegesMock = useUserPrivileges as jest.Mock; - -jest.mock('../../../../timelines/components/open_timeline/helpers'); - -const mockAddError = jest.fn(); -jest.mock('../../../../common/hooks/use_app_toasts', () => ({ - useAppToasts: () => ({ - addError: mockAddError, - }), -})); - -const mockDispatch = jest.fn(); -jest.mock('react-redux', () => { - const original = jest.requireActual('react-redux'); - return { - ...original, - useDispatch: () => mockDispatch, - }; -}); - -const renderNotesList = () => - render( - - - - ); - -describe('NotesList', () => { - beforeEach(() => { - useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, - }); - }); - - it('should render a note as a comment', () => { - const { getByTestId, getByText } = renderNotesList(); - expect(getByTestId(`${NOTES_COMMENT_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByText('note-1')).toBeInTheDocument(); - expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); - expect(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)).toBeInTheDocument(); - }); - - it('should render loading spinner if notes are being fetched', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); - }); - - it('should render no data message if no notes are present', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Succeeded, - }, - }, - }); - - const { getByText } = render( - - - - ); - - expect(getByText(NO_NOTES)).toBeInTheDocument(); - }); - - it('should render error toast if fetching notes fails', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - fetchNotesByDocumentIds: ReqStatus.Failed, - }, - error: { - ...mockGlobalState.notes.error, - fetchNotesByDocumentIds: { type: 'http', status: 500 }, - }, - }, - }); - - render( - - - - ); - - expect(mockAddError).toHaveBeenCalledWith(null, { - title: FETCH_NOTES_ERROR, - }); - }); - - it('should render ? in avatar is user is missing', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - entities: { - '1': { - eventId: '1', - noteId: '1', - note: 'note-1', - timelineId: '', - created: 1663882629000, - createdBy: 'elastic', - updated: 1663882629000, - updatedBy: null, - version: 'version', - }, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); - - expect(getByText('?')).toBeInTheDocument(); - }); - - it('should render create loading when user creates a new note', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - createNote: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(ADD_NOTE_LOADING_TEST_ID)).toBeInTheDocument(); - }); - - it('should dispatch delete action when user deletes a new note', () => { - const { getByTestId } = renderNotesList(); - - const deleteIcon = getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); - - expect(deleteIcon).toBeInTheDocument(); - expect(deleteIcon).not.toHaveAttribute('disabled'); - - deleteIcon.click(); - - expect(mockDispatch).toHaveBeenCalled(); - }); - - it('should have delete icons disabled and show spinner if a new note is being deleted', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - deleteNotes: ReqStatus.Loading, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toHaveAttribute('disabled'); - }); - - it('should not render a delete icon when the user does not have crud privileges', () => { - useUserPrivilegesMock.mockReturnValue({ - kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, - }); - const { queryByTestId } = renderNotesList(); - - const deleteIcon = queryByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); - - expect(deleteIcon).not.toBeInTheDocument(); - }); - - it('should render error toast if deleting a note fails', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - status: { - ...mockGlobalState.notes.status, - deleteNotes: ReqStatus.Failed, - }, - error: { - ...mockGlobalState.notes.error, - deleteNotes: { type: 'http', status: 500 }, - }, - }, - }); - - render( - - - - ); - - expect(mockAddError).toHaveBeenCalledWith(null, { - title: DELETE_NOTE_ERROR, - }); - }); - - it('should open timeline if user clicks on the icon', () => { - const queryTimelineById = jest.fn(); - (useQueryTimelineById as jest.Mock).mockReturnValue(queryTimelineById); - - const { getByTestId } = renderNotesList(); - - getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`).click(); - - expect(queryTimelineById).toHaveBeenCalledWith({ - duplicate: false, - onOpenTimeline: undefined, - timelineId: 'timeline-1', - timelineType: undefined, - unifiedComponentsInTimelineDisabled: false, - }); - }); - - it('should not render timeline icon if no timeline is related to the note', () => { - const store = createMockStore({ - ...mockGlobalState, - notes: { - ...mockGlobalState.notes, - entities: { - '1': { - eventId: '1', - noteId: '1', - note: 'note-1', - timelineId: '', - created: 1663882629000, - createdBy: 'elastic', - updated: 1663882629000, - updatedBy: 'elastic', - version: 'version', - }, - }, - }, - }); - - const { queryByTestId } = render( - - - - ); - - expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx deleted file mode 100644 index ba7d0b961ddef..0000000000000 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useCallback, useEffect, useState } from 'react'; -import { - EuiAvatar, - EuiButtonIcon, - EuiComment, - EuiCommentList, - EuiLoadingElastic, -} from '@elastic/eui'; -import { useDispatch, useSelector } from 'react-redux'; -import { FormattedRelative } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import { MarkdownRenderer } from '../../../../common/components/markdown_editor'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useQueryTimelineById } from '../../../../timelines/components/open_timeline/helpers'; -import { - ADD_NOTE_LOADING_TEST_ID, - DELETE_NOTE_BUTTON_TEST_ID, - NOTE_AVATAR_TEST_ID, - NOTES_COMMENT_TEST_ID, - NOTES_LOADING_TEST_ID, - OPEN_TIMELINE_BUTTON_TEST_ID, -} from './test_ids'; -import type { State } from '../../../../common/store'; -import type { Note } from '../../../../../common/api/timeline'; -import { - deleteNotes, - ReqStatus, - selectCreateNoteStatus, - selectDeleteNotesError, - selectDeleteNotesStatus, - selectFetchNotesByDocumentIdsError, - selectFetchNotesByDocumentIdsStatus, - selectSortedNotesByDocumentId, -} from '../../../../notes/store/notes.slice'; -import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; -import { useUserPrivileges } from '../../../../common/components/user_privileges'; - -export const ADDED_A_NOTE = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.addedANoteLabel', - { - defaultMessage: 'added a note', - } -); -export const FETCH_NOTES_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.fetchNotesErrorLabel', - { - defaultMessage: 'Error fetching notes', - } -); -export const NO_NOTES = i18n.translate('xpack.securitySolution.flyout.left.notes.noNotesLabel', { - defaultMessage: 'No notes have been created for this document', -}); -export const DELETE_NOTE = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.deleteNoteLabel', - { - defaultMessage: 'Delete note', - } -); -export const DELETE_NOTE_ERROR = i18n.translate( - 'xpack.securitySolution.flyout.left.notes.deleteNoteErrorLabel', - { - defaultMessage: 'Error deleting note', - } -); - -export interface NotesListProps { - /** - * Id of the document - */ - eventId: string; -} - -/** - * Renders a list of notes for the document. - * If a note belongs to a timeline, a timeline icon will be shown the top right corner. - * Also, a delete icon is shown in the top right corner to delete a note. - * When a note is being created, the component renders a loading spinner when the new note is about to be added. - */ -export const NotesList = memo(({ eventId }: NotesListProps) => { - const dispatch = useDispatch(); - const { addError: addErrorToast } = useAppToasts(); - const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); - const canDeleteNotes = kibanaSecuritySolutionsPrivileges.crud; - - const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( - 'unifiedComponentsInTimelineDisabled' - ); - - const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdsStatus(state)); - const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdsError(state)); - - const notes: Note[] = useSelector((state: State) => - selectSortedNotesByDocumentId(state, { - documentId: eventId, - sort: { field: 'created', direction: 'asc' }, - }) - ); - - const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); - - const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state)); - const deleteError = useSelector((state: State) => selectDeleteNotesError(state)); - const [deletingNoteId, setDeletingNoteId] = useState(''); - - const deleteNoteFc = useCallback( - (noteId: string) => { - setDeletingNoteId(noteId); - dispatch(deleteNotes({ ids: [noteId] })); - }, - [dispatch] - ); - - const queryTimelineById = useQueryTimelineById(); - const openTimeline = useCallback( - ({ timelineId }: { timelineId: string }) => - queryTimelineById({ - duplicate: false, - onOpenTimeline: undefined, - timelineId, - timelineType: undefined, - unifiedComponentsInTimelineDisabled, - }), - [queryTimelineById, unifiedComponentsInTimelineDisabled] - ); - - // show a toast if the fetch notes call fails - useEffect(() => { - if (fetchStatus === ReqStatus.Failed && fetchError) { - addErrorToast(null, { - title: FETCH_NOTES_ERROR, - }); - } - }, [addErrorToast, fetchError, fetchStatus]); - - useEffect(() => { - if (deleteStatus === ReqStatus.Failed && deleteError) { - addErrorToast(null, { - title: DELETE_NOTE_ERROR, - }); - } - }, [addErrorToast, deleteError, deleteStatus]); - - if (fetchStatus === ReqStatus.Loading) { - return ; - } - - if (fetchStatus === ReqStatus.Succeeded && notes.length === 0) { - return

{NO_NOTES}

; - } - - return ( - - {notes.map((note, index) => ( - {note.created && }} - event={ADDED_A_NOTE} - actions={ - <> - {note.timelineId && note.timelineId.length > 0 && ( - openTimeline(note)} - /> - )} - {canDeleteNotes && ( - deleteNoteFc(note.noteId)} - disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading} - isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading} - /> - )} - - } - timelineAvatar={ - - } - > - {note.note || ''} - - ))} - {createStatus === ReqStatus.Loading && ( - - )} - - ); -}); - -NotesList.displayName = 'NotesList'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 23be7cc9b801f..0779f3c135b2d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -125,12 +125,6 @@ export const INVESTIGATION_GUIDE_LOADING_TEST_ID = `${INVESTIGATION_GUIDE_TEST_I /* Notes */ -export const NOTES_LOADING_TEST_ID = `${PREFIX}NotesLoading` as const; -export const NOTES_COMMENT_TEST_ID = `${PREFIX}NotesComment` as const; -export const ADD_NOTE_LOADING_TEST_ID = `${PREFIX}AddNotesLoading` as const; -export const ADD_NOTE_MARKDOWN_TEST_ID = `${PREFIX}AddNotesMarkdown` as const; -export const ADD_NOTE_BUTTON_TEST_ID = `${PREFIX}AddNotesButton` as const; -export const NOTE_AVATAR_TEST_ID = `${PREFIX}NoteAvatar` as const; -export const DELETE_NOTE_BUTTON_TEST_ID = `${PREFIX}DeleteNotesButton` as const; +export const ATTACH_TO_TIMELINE_CALLOUT_TEST_ID = `${PREFIX}AttachToTimelineCallout` as const; export const ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID = `${PREFIX}AttachToTimelineCheckbox` as const; -export const OPEN_TIMELINE_BUTTON_TEST_ID = `${PREFIX}OpenTimelineButton` as const; +export const SAVE_TIMELINE_BUTTON_TEST_ID = `${PREFIX}SaveTimelineButton` as const; diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 4c9542458c304..eb25eed9f2816 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -75,6 +75,20 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => { return response; }; +/** + * Fetches all the notes for an array of saved object ids + */ +export const fetchNotesBySaveObjectIds = async (savedObjectIds: string[]) => { + const response = await KibanaServices.get().http.get<{ notes: Note[]; totalCount: number }>( + NOTE_URL, + { + query: { savedObjectIds }, + version: '2023-10-31', + } + ); + return response; +}; + /** * Deletes multiple notes */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx rename to x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx index 481776bb51413..f20323da6085d 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/add_note.test.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.test.tsx @@ -5,26 +5,18 @@ * 2.0. */ -import * as uuid from 'uuid'; import { render } from '@testing-library/react'; import React from 'react'; -import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock'; -import { AddNote, CREATE_NOTE_ERROR } from './add_note'; -import { - ADD_NOTE_BUTTON_TEST_ID, - ADD_NOTE_MARKDOWN_TEST_ID, - ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID, -} from './test_ids'; -import { ReqStatus } from '../../../../notes/store/notes.slice'; -import { TimelineId } from '../../../../../common/types'; import userEvent from '@testing-library/user-event'; -import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; -import { Flyouts } from '../../shared/constants/flyouts'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import { AddNote, CREATE_NOTE_ERROR } from './add_note'; +import { ADD_NOTE_BUTTON_TEST_ID, ADD_NOTE_MARKDOWN_TEST_ID } from './test_ids'; +import { ReqStatus } from '../store/notes.slice'; -jest.mock('../../shared/hooks/use_which_flyout'); +jest.mock('../../flyout/document_details/shared/hooks/use_which_flyout'); const mockAddError = jest.fn(); -jest.mock('../../../../common/hooks/use_app_toasts', () => ({ +jest.mock('../../common/hooks/use_app_toasts', () => ({ useAppToasts: () => ({ addError: mockAddError, }), @@ -52,7 +44,6 @@ describe('AddNote', () => { expect(getByTestId(ADD_NOTE_MARKDOWN_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ADD_NOTE_BUTTON_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toBeInTheDocument(); }); it('should create note', async () => { @@ -76,6 +67,19 @@ describe('AddNote', () => { expect(addButton).not.toHaveAttribute('disabled'); }); + it('should disable add button always is disableButton props is true', async () => { + const { getByTestId } = render( + + + + ); + + await userEvent.type(getByTestId('euiMarkdownEditorTextArea'), 'new note'); + + const addButton = getByTestId(ADD_NOTE_BUTTON_TEST_ID); + expect(addButton).toHaveAttribute('disabled'); + }); + it('should render the add note button in loading state while creating a new note', () => { const store = createMockStore({ ...mockGlobalState, @@ -123,63 +127,4 @@ describe('AddNote', () => { title: CREATE_NOTE_ERROR, }); }); - - it('should disable attach to timeline checkbox if flyout is not open from timeline', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.securitySolution); - - const { getByTestId } = renderAddNote(); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toHaveAttribute('disabled'); - }); - - it('should disable attach to timeline checkbox if active timeline is not saved', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - - const store = createMockStore({ - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - }, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).toHaveAttribute('disabled'); - }); - - it('should have attach to timeline checkbox enabled', () => { - (useWhichFlyout as jest.Mock).mockReturnValue(Flyouts.timeline); - - const store = createMockStore({ - ...mockGlobalState, - timeline: { - ...mockGlobalState.timeline, - timelineById: { - ...mockGlobalState.timeline.timelineById, - [TimelineId.active]: { - ...mockGlobalState.timeline.timelineById[TimelineId.test], - savedObjectId: uuid.v4(), - }, - }, - }, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toHaveAttribute('disabled'); - }); }); diff --git a/x-pack/plugins/security_solution/public/notes/components/add_note.tsx b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx new file mode 100644 index 0000000000000..d54e0e42c86eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/add_note.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiButton, + EuiComment, + EuiCommentList, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../common/lib/kibana'; +import { ADD_NOTE_BUTTON_TEST_ID, ADD_NOTE_MARKDOWN_TEST_ID } from './test_ids'; +import { useAppToasts } from '../../common/hooks/use_app_toasts'; +import type { State } from '../../common/store'; +import { + createNote, + ReqStatus, + selectCreateNoteError, + selectCreateNoteStatus, +} from '../store/notes.slice'; +import { MarkdownEditor } from '../../common/components/markdown_editor'; + +export const MARKDOWN_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.notes.addNote.markdownAriaLabel', + { + defaultMessage: 'Note', + } +); +export const ADD_NOTE_BUTTON = i18n.translate('xpack.securitySolution.notes.addNote.buttonLabel', { + defaultMessage: 'Add note', +}); +export const CREATE_NOTE_ERROR = i18n.translate( + 'xpack.securitySolution.notes.createNote.errorLabel', + { + defaultMessage: 'Error create note', + } +); + +export interface AddNewNoteProps { + /** + * Id of the document + */ + eventId?: string; + /** + * Id of the timeline + */ + timelineId?: string | null | undefined; + /** + * Allows to override the default state of the add note button + */ + disableButton?: boolean; + /** + * Children to render between the markdown and the add note button + */ + children?: React.ReactNode; +} + +/** + * Renders a markdown editor and an add button to create new notes. + * The checkbox is automatically checked if the flyout is opened from a timeline and that timeline is saved. It is disabled if the flyout is NOT opened from a timeline. + */ +export const AddNote = memo( + ({ eventId, timelineId, disableButton = false, children }: AddNewNoteProps) => { + const { telemetry } = useKibana().services; + const dispatch = useDispatch(); + const { addError: addErrorToast } = useAppToasts(); + const [editorValue, setEditorValue] = useState(''); + const [isMarkdownInvalid, setIsMarkdownInvalid] = useState(false); + + const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); + const createError = useSelector((state: State) => selectCreateNoteError(state)); + + const addNote = useCallback(() => { + dispatch( + createNote({ + note: { + timelineId: timelineId || '', + eventId, + note: editorValue, + }, + }) + ); + telemetry.reportAddNoteFromExpandableFlyoutClicked({ + isRelatedToATimeline: timelineId != null, + }); + setEditorValue(''); + }, [dispatch, editorValue, eventId, telemetry, timelineId]); + + // show a toast if the create note call fails + useEffect(() => { + if (createStatus === ReqStatus.Failed && createError) { + addErrorToast(null, { + title: CREATE_NOTE_ERROR, + }); + } + }, [addErrorToast, createError, createStatus]); + + const buttonDisabled = useMemo( + () => disableButton || editorValue.trim().length === 0 || isMarkdownInvalid, + [disableButton, editorValue, isMarkdownInvalid] + ); + + return ( + <> + + + + + + + {children && ( + <> + {children} + + + )} + + + + {ADD_NOTE_BUTTON} + + + + + ); + } +); + +AddNote.displayName = 'AddNote'; diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx new file mode 100644 index 0000000000000..88224604a0ece --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { DELETE_NOTE_ERROR, DeleteNoteButtonIcon } from './delete_note_button'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import type { Note } from '../../../common/api/timeline'; +import { DELETE_NOTE_BUTTON_TEST_ID } from './test_ids'; +import { ReqStatus } from '..'; + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockAddError = jest.fn(); +jest.mock('../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + +const note: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timelineId', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const index = 0; + +describe('DeleteNoteButtonIcon', () => { + it('should render the delete icon', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`)).toBeInTheDocument(); + }); + + it('should have delete icons disabled and show spinner if a new note is being deleted', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + deleteNotes: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toHaveAttribute('disabled'); + }); + + it('should dispatch delete action when user deletes a new note', () => { + const { getByTestId } = render( + + + + ); + + const deleteIcon = getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); + + expect(deleteIcon).toBeInTheDocument(); + expect(deleteIcon).not.toHaveAttribute('disabled'); + + deleteIcon.click(); + + expect(mockDispatch).toHaveBeenCalled(); + }); + + it('should render error toast if deleting a note fails', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + deleteNotes: ReqStatus.Failed, + }, + error: { + ...mockGlobalState.notes.error, + deleteNotes: { type: 'http', status: 500 }, + }, + }, + }); + + render( + + + + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: DELETE_NOTE_ERROR, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx new file mode 100644 index 0000000000000..3f9e757d3f5a5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_note_button.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { DELETE_NOTE_BUTTON_TEST_ID } from './test_ids'; +import type { State } from '../../common/store'; +import type { Note } from '../../../common/api/timeline'; +import { + deleteNotes, + ReqStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, +} from '../store/notes.slice'; +import { useAppToasts } from '../../common/hooks/use_app_toasts'; + +export const DELETE_NOTE = i18n.translate('xpack.securitySolution.notes.deleteNote.buttonLabel', { + defaultMessage: 'Delete note', +}); +export const DELETE_NOTE_ERROR = i18n.translate( + 'xpack.securitySolution.notes.deleteNote.errorLabel', + { + defaultMessage: 'Error deleting note', + } +); + +export interface DeleteNoteButtonIconProps { + /** + * The note that contains the id of the timeline to open + */ + note: Note; + /** + * The index of the note in the list of notes (used to have unique data-test-subj) + */ + index: number; +} + +/** + * Renders a button to delete a note + */ +export const DeleteNoteButtonIcon = memo(({ note, index }: DeleteNoteButtonIconProps) => { + const dispatch = useDispatch(); + const { addError: addErrorToast } = useAppToasts(); + + const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state)); + const deleteError = useSelector((state: State) => selectDeleteNotesError(state)); + const [deletingNoteId, setDeletingNoteId] = useState(''); + + const deleteNoteFc = useCallback( + (noteId: string) => { + setDeletingNoteId(noteId); + dispatch(deleteNotes({ ids: [noteId] })); + }, + [dispatch] + ); + + useEffect(() => { + if (deleteStatus === ReqStatus.Failed && deleteError) { + addErrorToast(null, { + title: DELETE_NOTE_ERROR, + }); + } + }, [addErrorToast, deleteError, deleteStatus]); + + return ( + deleteNoteFc(note.noteId)} + disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading} + isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading} + /> + ); +}); + +DeleteNoteButtonIcon.displayName = 'DeleteNoteButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx new file mode 100644 index 0000000000000..d32b508d03037 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/notes_list.test.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, within } from '@testing-library/react'; +import React from 'react'; +import { + ADD_NOTE_LOADING_TEST_ID, + DELETE_NOTE_BUTTON_TEST_ID, + NOTE_AVATAR_TEST_ID, + NOTES_COMMENT_TEST_ID, + OPEN_TIMELINE_BUTTON_TEST_ID, +} from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../common/mock'; +import { NotesList } from './notes_list'; +import { ReqStatus } from '../store/notes.slice'; +import { useUserPrivileges } from '../../common/components/user_privileges'; +import type { Note } from '../../../common/api/timeline'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; + +jest.mock('../../common/hooks/use_experimental_features'); + +jest.mock('../../common/components/user_privileges'); +const useUserPrivilegesMock = useUserPrivileges as jest.Mock; + +const mockNote: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const mockOptions = { hideTimelineIcon: true }; + +describe('NotesList', () => { + beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + + useUserPrivilegesMock.mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true, read: true }, + }); + }); + + it('should render a note as a comment', () => { + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId(`${NOTES_COMMENT_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByText('note-1')).toBeInTheDocument(); + expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).toBeInTheDocument(); + expect(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)).toBeInTheDocument(); + }); + + it('should render ? in avatar is user is missing', () => { + const customMockNotes = [ + { + ...mockNote, + updatedBy: undefined, + }, + ]; + + const { getByTestId } = render( + + + + ); + const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); + + expect(getByText('?')).toBeInTheDocument(); + }); + + it('should render create loading when user creates a new note', () => { + const store = createMockStore({ + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + status: { + ...mockGlobalState.notes.status, + createNote: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(ADD_NOTE_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render a delete icon when the user does not have crud privileges', () => { + useUserPrivilegesMock.mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: false, read: true }, + }); + const { queryByTestId } = render( + + + + ); + + const deleteIcon = queryByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`); + + expect(deleteIcon).not.toBeInTheDocument(); + }); + + it('should not render timeline icon if no timeline is related to the note', () => { + const customMockNotes = [ + { + ...mockNote, + timelineId: '', + }, + ]; + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); + }); + + it('should not render timeline icon if it should be hidden', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx new file mode 100644 index 0000000000000..47dcf89b06452 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/notes_list.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiAvatar, EuiComment, EuiCommentList, EuiLoadingElastic } from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import { FormattedRelative } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { OpenFlyoutButtonIcon } from './open_flyout_button'; +import { OpenTimelineButtonIcon } from './open_timeline_button'; +import { DeleteNoteButtonIcon } from './delete_note_button'; +import { MarkdownRenderer } from '../../common/components/markdown_editor'; +import { ADD_NOTE_LOADING_TEST_ID, NOTE_AVATAR_TEST_ID, NOTES_COMMENT_TEST_ID } from './test_ids'; +import type { State } from '../../common/store'; +import type { Note } from '../../../common/api/timeline'; +import { ReqStatus, selectCreateNoteStatus } from '../store/notes.slice'; +import { useUserPrivileges } from '../../common/components/user_privileges'; + +export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.notes.addedANoteLabel', { + defaultMessage: 'added a note', +}); +export const DELETE_NOTE = i18n.translate('xpack.securitySolution.notes.deleteNoteLabel', { + defaultMessage: 'Delete note', +}); + +export interface NotesListProps { + /** + * The notes to display as a EuiComment + */ + notes: Note[]; + /** + * Options to customize the rendering of the notes list + */ + options?: { + /** + * If true, the timeline icon will be hidden (this is useful for the timeline Notes tab) + */ + hideTimelineIcon?: boolean; + /** + * If true, the flyout icon will be hidden (this is useful for the flyout Notes tab) + */ + hideFlyoutIcon?: boolean; + }; +} + +/** + * Renders a list of notes for the document. + * If a note belongs to a timeline, a timeline icon will be shown the top right corner. + * Also, a delete icon is shown in the top right corner to delete a note. + * When a note is being created, the component renders a loading spinner when the new note is about to be added. + */ +export const NotesList = memo(({ notes, options }: NotesListProps) => { + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + const canDeleteNotes = kibanaSecuritySolutionsPrivileges.crud; + + const createStatus = useSelector((state: State) => selectCreateNoteStatus(state)); + + return ( + + {notes.map((note, index) => ( + {note.created && }} + event={ADDED_A_NOTE} + actions={ + <> + {note.eventId && !options?.hideFlyoutIcon && ( + + )} + {note.timelineId && note.timelineId.length > 0 && !options?.hideTimelineIcon && ( + + )} + {canDeleteNotes && } + + } + timelineAvatar={ + + } + > + {note.note || ''} + + ))} + {createStatus === ReqStatus.Loading && ( + + )} + + ); +}); + +NotesList.displayName = 'NotesList'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx new file mode 100644 index 0000000000000..eed5e5bcbd5da --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../common/mock'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from './test_ids'; +import { OpenFlyoutButtonIcon } from './open_flyout_button'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; +import { useSourcererDataView } from '../../sourcerer/containers'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../sourcerer/containers'); + +const mockEventId = 'eventId'; +const mockTimelineId = 'timelineId'; + +describe('OpenFlyoutButtonIcon', () => { + it('should render the chevron icon', () => { + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout: jest.fn() }); + (useSourcererDataView as jest.Mock).mockReturnValue({ selectedPatterns: [] }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(OPEN_FLYOUT_BUTTON_TEST_ID)).toBeInTheDocument(); + }); + + it('should call the expandable flyout api when the button is clicked', () => { + const openFlyout = jest.fn(); + (useExpandableFlyoutApi as jest.Mock).mockReturnValue({ openFlyout }); + (useSourcererDataView as jest.Mock).mockReturnValue({ selectedPatterns: ['test1', 'test2'] }); + + const { getByTestId } = render( + + + + ); + + const button = getByTestId(OPEN_FLYOUT_BUTTON_TEST_ID); + button.click(); + + expect(openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: mockEventId, + indexName: 'test1,test2', + scopeId: mockTimelineId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx new file mode 100644 index 0000000000000..0c541cc95740c --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_flyout_button.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from './test_ids'; +import { useSourcererDataView } from '../../sourcerer/containers'; +import { SourcererScopeName } from '../../sourcerer/store/model'; +import { useKibana } from '../../common/lib/kibana'; +import { DocumentDetailsRightPanelKey } from '../../flyout/document_details/shared/constants/panel_keys'; + +export const OPEN_FLYOUT_BUTTON = i18n.translate( + 'xpack.securitySolution.notes.openFlyoutButtonLabel', + { + defaultMessage: 'Expand event details', + } +); + +export interface OpenFlyoutButtonIconProps { + /** + * Id of the event to render in the flyout + */ + eventId: string; + /** + * Id of the timeline to pass to the flyout for scope + */ + timelineId: string; +} + +/** + * Renders a button to open the alert and event details flyout + */ +export const OpenFlyoutButtonIcon = memo(({ eventId, timelineId }: OpenFlyoutButtonIconProps) => { + const { selectedPatterns } = useSourcererDataView(SourcererScopeName.timeline); + + const { telemetry } = useKibana().services; + const { openFlyout } = useExpandableFlyoutApi(); + + const handleClick = useCallback(() => { + openFlyout({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName: selectedPatterns.join(','), + scopeId: timelineId, + }, + }, + }); + telemetry.reportDetailsFlyoutOpened({ + location: timelineId, + panel: 'right', + }); + }, [eventId, openFlyout, selectedPatterns, telemetry, timelineId]); + + return ( + + ); +}); + +OpenFlyoutButtonIcon.displayName = 'OpenFlyoutButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx new file mode 100644 index 0000000000000..85ecfce68e5d9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { OpenTimelineButtonIcon } from './open_timeline_button'; +import type { Note } from '../../../common/api/timeline'; +import { OPEN_TIMELINE_BUTTON_TEST_ID } from './test_ids'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; + +jest.mock('../../common/hooks/use_experimental_features'); +jest.mock('../../timelines/components/open_timeline/helpers'); + +const note: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timelineId', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const index = 0; + +describe('OpenTimelineButtonIcon', () => { + it('should render the timeline icon', () => { + const { getByTestId } = render(); + + expect(getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`)).toBeInTheDocument(); + }); + + it('should call openTimeline with the correct values', () => { + const openTimeline = jest.fn(); + (useQueryTimelineById as jest.Mock).mockReturnValue(openTimeline); + + const unifiedComponentsInTimelineDisabled = false; + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue( + unifiedComponentsInTimelineDisabled + ); + + const { getByTestId } = render(); + + const button = getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`); + button.click(); + + expect(openTimeline).toHaveBeenCalledWith({ + duplicate: false, + onOpenTimeline: undefined, + timelineId: note.timelineId, + timelineType: undefined, + unifiedComponentsInTimelineDisabled, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx new file mode 100644 index 0000000000000..531983429acd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/open_timeline_button.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { EuiButtonIcon } from '@elastic/eui'; +import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; +import { useQueryTimelineById } from '../../timelines/components/open_timeline/helpers'; +import { OPEN_TIMELINE_BUTTON_TEST_ID } from './test_ids'; +import type { Note } from '../../../common/api/timeline'; + +export interface OpenTimelineButtonIconProps { + /** + * The note that contains the id of the timeline to open + */ + note: Note; + /** + * The index of the note in the list of notes (used to have unique data-test-subj) + */ + index: number; +} + +/** + * Renders a button to open the timeline associated with a note + */ +export const OpenTimelineButtonIcon = memo(({ note, index }: OpenTimelineButtonIconProps) => { + const unifiedComponentsInTimelineDisabled = useIsExperimentalFeatureEnabled( + 'unifiedComponentsInTimelineDisabled' + ); + + const queryTimelineById = useQueryTimelineById(); + const openTimeline = useCallback( + ({ timelineId }: { timelineId: string }) => + queryTimelineById({ + duplicate: false, + onOpenTimeline: undefined, + timelineId, + timelineType: undefined, + unifiedComponentsInTimelineDisabled, + }), + [queryTimelineById, unifiedComponentsInTimelineDisabled] + ); + + return ( + openTimeline(note)} + /> + ); +}); + +OpenTimelineButtonIcon.displayName = 'OpenTimelineButtonIcon'; diff --git a/x-pack/plugins/security_solution/public/notes/components/test_ids.ts b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts new file mode 100644 index 0000000000000..6c63a43f365ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/test_ids.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PREFIX = 'securitySolutionNotes' as const; + +export const NOTES_LOADING_TEST_ID = `${PREFIX}NotesLoading` as const; +export const NOTES_COMMENT_TEST_ID = `${PREFIX}NotesComment` as const; +export const ADD_NOTE_LOADING_TEST_ID = `${PREFIX}AddNotesLoading` as const; +export const ADD_NOTE_MARKDOWN_TEST_ID = `${PREFIX}AddNotesMarkdown` as const; +export const ADD_NOTE_BUTTON_TEST_ID = `${PREFIX}AddNotesButton` as const; +export const NOTE_AVATAR_TEST_ID = `${PREFIX}NoteAvatar` as const; +export const DELETE_NOTE_BUTTON_TEST_ID = `${PREFIX}DeleteNotesButton` as const; +export const OPEN_TIMELINE_BUTTON_TEST_ID = `${PREFIX}OpenTimelineButton` as const; +export const OPEN_FLYOUT_BUTTON_TEST_ID = `${PREFIX}OpenFlyoutButton` as const; +export const TIMELINE_DESCRIPTION_COMMENT_TEST_ID = `${PREFIX}TimelineDescriptionComment` as const; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 5825a170bf1cf..396940c892a6e 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -42,6 +42,9 @@ import { userSelectedRowForDeletion, userSortedNotes, selectSortedNotesByDocumentId, + fetchNotesBySavedObjectIds, + selectNotesBySavedObjectId, + selectSortedNotesBySavedObjectId, } from './notes.slice'; import type { NotesState } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; @@ -72,11 +75,18 @@ const initialNonEmptyState = { ids: [mockNote1.noteId, mockNote2.noteId], status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNotes: null, fetchNotes: null }, + error: { + fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, + createNote: null, + deleteNotes: null, + fetchNotes: null, + }, pagination: { page: 1, perPage: 10, @@ -180,6 +190,88 @@ describe('notesSlice', () => { }); }); + describe('fetchNotesBySavedObjectIds', () => { + it('should set correct status state when fetching notes by saved object ids', () => { + const action = { type: fetchNotesBySavedObjectIds.pending.type }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Loading, + }, + }); + }); + + it('should set correct state when success on fetch notes by saved object id ids on an empty state', () => { + const action = { + type: fetchNotesBySavedObjectIds.fulfilled.type, + payload: { + entities: { + notes: { + [mockNote1.noteId]: mockNote1, + }, + }, + result: [mockNote1.noteId], + }, + }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + entities: action.payload.entities.notes, + ids: action.payload.result, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }); + }); + + it('should replace notes when success on fetch notes by saved object id ids on a non-empty state', () => { + const newMockNote = { ...mockNote1, timelineId: 'timelineId' }; + const action = { + type: fetchNotesBySavedObjectIds.fulfilled.type, + payload: { + entities: { + notes: { + [newMockNote.noteId]: newMockNote, + }, + }, + result: [newMockNote.noteId], + }, + }; + + expect(notesReducer(initialNonEmptyState, action)).toEqual({ + ...initalEmptyState, + entities: { + [newMockNote.noteId]: newMockNote, + [mockNote2.noteId]: mockNote2, + }, + ids: [newMockNote.noteId, mockNote2.noteId], + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }); + }); + + it('should set correct error state when failing to fetch notes by saved object ids', () => { + const action = { type: fetchNotesBySavedObjectIds.rejected.type, error: 'error' }; + + expect(notesReducer(initalEmptyState, action)).toEqual({ + ...initalEmptyState, + status: { + ...initalEmptyState.status, + fetchNotesBySavedObjectIds: ReqStatus.Failed, + }, + error: { + ...initalEmptyState.error, + fetchNotesBySavedObjectIds: 'error', + }, + }); + }); + }); + describe('createNote', () => { it('should set correct status state when creating a note', () => { const action = { type: createNote.pending.type }; @@ -516,7 +608,7 @@ describe('notesSlice', () => { expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0); }); - it('should return all notes sorted dor an existing document id', () => { + it('should return all notes sorted for an existing document id', () => { const oldestNote = { eventId: '1', // should be a valid id based on mockTimelineData noteId: '1', @@ -573,6 +665,89 @@ describe('notesSlice', () => { ).toHaveLength(0); }); + it('should return all notes for an existing saved object id', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, 'timeline-1')).toEqual([ + mockGlobalState.notes.entities['1'], + ]); + }); + + it('should return no notes if saved object id does not exist', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, 'wrong-saved-object-id')).toHaveLength(0); + }); + + it('should return no notes if saved object id is empty string', () => { + expect(selectNotesBySavedObjectId(mockGlobalState, '')).toHaveLength(0); + }); + + it('should return all notes sorted for an existing saved object id', () => { + const oldestNote = { + eventId: '1', // should be a valid id based on mockTimelineData + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', + }; + const newestNote = { + ...oldestNote, + noteId: '2', + created: 1663882689000, + }; + + const state = { + ...mockGlobalState, + notes: { + ...mockGlobalState.notes, + entities: { + '1': oldestNote, + '2': newestNote, + }, + ids: ['1', '2'], + }, + }; + + const ascResult = selectSortedNotesBySavedObjectId(state, { + savedObjectId: 'timeline-1', + sort: { field: 'created', direction: 'asc' }, + }); + expect(ascResult[0]).toEqual(oldestNote); + expect(ascResult[1]).toEqual(newestNote); + + const descResult = selectSortedNotesBySavedObjectId(state, { + savedObjectId: 'timeline-1', + sort: { field: 'created', direction: 'desc' }, + }); + expect(descResult[0]).toEqual(newestNote); + expect(descResult[1]).toEqual(oldestNote); + }); + + it('should also return no notes if saved object id does not exist', () => { + expect( + selectSortedNotesBySavedObjectId(mockGlobalState, { + savedObjectId: 'wrong-document-id', + sort: { + field: 'created', + direction: 'desc', + }, + }) + ).toHaveLength(0); + }); + + it('should also return no notes if saved object id is empty string', () => { + expect( + selectSortedNotesBySavedObjectId(mockGlobalState, { + savedObjectId: '', + sort: { + field: 'created', + direction: 'desc', + }, + }) + ).toHaveLength(0); + }); + it('should select notes pagination', () => { const state = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 4f333103a2a25..3f0439e7298e4 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -14,6 +14,7 @@ import { deleteNotes as deleteNotesApi, fetchNotes as fetchNotesApi, fetchNotesByDocumentIds as fetchNotesByDocumentIdsApi, + fetchNotesBySaveObjectIds as fetchNotesBySaveObjectIdsApi, } from '../api/api'; import type { NormalizedEntities, NormalizedEntity } from './normalize'; import { normalizeEntities, normalizeEntity } from './normalize'; @@ -26,7 +27,7 @@ export enum ReqStatus { Failed = 'failed', } -interface HttpError { +export interface HttpError { type: 'http'; status: number; } @@ -34,12 +35,14 @@ interface HttpError { export interface NotesState extends EntityState { status: { fetchNotesByDocumentIds: ReqStatus; + fetchNotesBySavedObjectIds: ReqStatus; createNote: ReqStatus; deleteNotes: ReqStatus; fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; + fetchNotesBySavedObjectIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; deleteNotes: SerializedError | HttpError | null; fetchNotes: SerializedError | HttpError | null; @@ -66,12 +69,14 @@ const notesAdapter = createEntityAdapter({ export const initialNotesState: NotesState = notesAdapter.getInitialState({ status: { fetchNotesByDocumentIds: ReqStatus.Idle, + fetchNotesBySavedObjectIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, + fetchNotesBySavedObjectIds: null, createNote: null, deleteNotes: null, fetchNotes: null, @@ -101,6 +106,16 @@ export const fetchNotesByDocumentIds = createAsyncThunk< return normalizeEntities(res.notes); }); +export const fetchNotesBySavedObjectIds = createAsyncThunk< + NormalizedEntities, + { savedObjectIds: string[] }, + {} +>('notes/fetchNotesBySavedObjectIds', async (args) => { + const { savedObjectIds } = args; + const res = await fetchNotesBySaveObjectIdsApi(savedObjectIds); + return normalizeEntities(res.notes); +}); + export const fetchNotes = createAsyncThunk< NormalizedEntities & { totalCount: number }, { @@ -198,6 +213,17 @@ const notesSlice = createSlice({ state.status.fetchNotesByDocumentIds = ReqStatus.Failed; state.error.fetchNotesByDocumentIds = action.payload ?? action.error; }) + .addCase(fetchNotesBySavedObjectIds.pending, (state) => { + state.status.fetchNotesBySavedObjectIds = ReqStatus.Loading; + }) + .addCase(fetchNotesBySavedObjectIds.fulfilled, (state, action) => { + notesAdapter.upsertMany(state, action.payload.entities.notes); + state.status.fetchNotesBySavedObjectIds = ReqStatus.Succeeded; + }) + .addCase(fetchNotesBySavedObjectIds.rejected, (state, action) => { + state.status.fetchNotesBySavedObjectIds = ReqStatus.Failed; + state.error.fetchNotesBySavedObjectIds = action.payload ?? action.error; + }) .addCase(createNote.pending, (state) => { state.status.createNote = ReqStatus.Loading; }) @@ -253,6 +279,12 @@ export const selectFetchNotesByDocumentIdsStatus = (state: State) => export const selectFetchNotesByDocumentIdsError = (state: State) => state.notes.error.fetchNotesByDocumentIds; +export const selectFetchNotesBySavedObjectIdsStatus = (state: State) => + state.notes.status.fetchNotesBySavedObjectIds; + +export const selectFetchNotesBySavedObjectIdsError = (state: State) => + state.notes.error.fetchNotesBySavedObjectIds; + export const selectCreateNoteStatus = (state: State) => state.notes.status.createNote; export const selectCreateNoteError = (state: State) => state.notes.error.createNote; @@ -280,6 +312,12 @@ export const selectNotesByDocumentId = createSelector( (notes, documentId) => notes.filter((note) => note.eventId === documentId) ); +export const selectNotesBySavedObjectId = createSelector( + [selectAllNotes, (state: State, savedObjectId: string) => savedObjectId], + (notes, savedObjectId) => + savedObjectId.length > 0 ? notes.filter((note) => note.timelineId === savedObjectId) : [] +); + export const selectSortedNotesByDocumentId = createSelector( [ selectAllNotes, @@ -305,6 +343,34 @@ export const selectSortedNotesByDocumentId = createSelector( } ); +export const selectSortedNotesBySavedObjectId = createSelector( + [ + selectAllNotes, + ( + state: State, + { + savedObjectId, + sort, + }: { savedObjectId: string; sort: { field: keyof Note; direction: 'asc' | 'desc' } } + ) => ({ savedObjectId, sort }), + ], + (notes, { savedObjectId, sort }) => { + const { field, direction } = sort; + if (savedObjectId.length === 0) { + return []; + } + return notes + .filter((note: Note) => note.timelineId === savedObjectId) + .sort((first: Note, second: Note) => { + const a = first[field]; + const b = second[field]; + if (a == null) return 1; + if (b == null) return -1; + return direction === 'asc' ? (a > b ? 1 : -1) : a > b ? -1 : 1; + }); + } +); + export const { userSelectedPage, userSelectedPerPage, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx index 2f923a12e3f33..8a05a42f7cd25 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.test.tsx @@ -62,6 +62,27 @@ describe('SaveTimelineButton', () => { expect(queryByTestId('save-timeline-modal')).not.toBeInTheDocument(); }); + it('should override the default text in the button', async () => { + (useUserPrivileges as jest.Mock).mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true }, + }); + mockGetState.mockReturnValue({ + ...mockTimelineModel, + status: TimelineStatusEnum.active, + isSaving: false, + }); + (useCreateTimeline as jest.Mock).mockReturnValue({}); + + const { getByText, queryByText } = render( + + + + ); + + expect(queryByText('Save')).not.toBeInTheDocument(); + expect(getByText('TEST')).toBeInTheDocument(); + }); + it('should open the timeline save modal', async () => { (useUserPrivileges as jest.Mock).mockReturnValue({ kibanaSecuritySolutionsPrivileges: { crud: true }, diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx index f89471e36827f..3a85022db9fbf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/save_timeline_button.tsx @@ -21,60 +21,77 @@ export interface SaveTimelineButtonProps { * Id of the timeline to be displayed in the bottom bar and within the modal */ timelineId: string; + /** + * Ability to customize the text of the button + */ + buttonText?: string; + /** + * Optional data-test-subj value + */ + ['data-test-subj']?: string; } /** - * Button that allows user to save the timeline. Clicking it opens the `SaveTimelineModal` + * Button that allows user to save the timeline. Clicking it opens the `SaveTimelineModal`. + * The default 'Save' button text can be overridden by passing the `buttonText` prop. */ -export const SaveTimelineButton = React.memo(({ timelineId }) => { - const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState(false); - const toggleSaveTimeline = useCallback(() => setShowEditTimelineOverlay((prev) => !prev), []); +export const SaveTimelineButton = React.memo( + ({ + timelineId, + buttonText = i18n.SAVE, + 'data-test-subj': dataTestSubj = 'timeline-modal-save-timeline', + }) => { + const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState(false); + const toggleSaveTimeline = useCallback(() => setShowEditTimelineOverlay((prev) => !prev), []); - // Case: 1 - // check if user has crud privileges so that user can be allowed to edit the timeline - // Case: 2 - // TODO: User may have Crud privileges but they may not have access to timeline index. - // Do we need to check that? - const { - kibanaSecuritySolutionsPrivileges: { crud: canEditTimelinePrivilege }, - } = useUserPrivileges(); + // Case: 1 + // check if user has crud privileges so that user can be allowed to edit the timeline + // Case: 2 + // TODO: User may have Crud privileges but they may not have access to timeline index. + // Do we need to check that? + const { + kibanaSecuritySolutionsPrivileges: { crud: canEditTimelinePrivilege }, + } = useUserPrivileges(); - const { status, isSaving } = useSelector((state: State) => selectTimelineById(state, timelineId)); + const { status, isSaving } = useSelector((state: State) => + selectTimelineById(state, timelineId) + ); - const canSaveTimeline = canEditTimelinePrivilege && status !== TimelineStatusEnum.immutable; - const isUnsaved = status === TimelineStatusEnum.draft; - const unauthorizedMessage = canSaveTimeline ? null : i18n.CALL_OUT_UNAUTHORIZED_MSG; + const canSaveTimeline = canEditTimelinePrivilege && status !== TimelineStatusEnum.immutable; + const isUnsaved = status === TimelineStatusEnum.draft; + const unauthorizedMessage = canSaveTimeline ? null : i18n.CALL_OUT_UNAUTHORIZED_MSG; - return ( - <> - - + - {i18n.SAVE} - - - {showEditTimelineOverlay && canSaveTimeline ? ( - - ) : null} - - ); -}); + + {buttonText} + + + {showEditTimelineOverlay && canSaveTimeline ? ( + + ) : null} + + ); + } +); SaveTimelineButton.displayName = 'SaveTimelineButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx new file mode 100644 index 0000000000000..71432267ac0da --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/old_notes.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filter, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiHorizontalRule, +} from '@elastic/eui'; + +import React, { Fragment, useCallback, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; + +import type { EuiTheme } from '@kbn/react-kibana-context-styled'; +import { timelineActions } from '../../store'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { TimelineStatusEnum } from '../../../../common/api/timeline'; +import { appSelectors } from '../../../common/store/app'; +import { AddNote } from './add_note'; +import { CREATED_BY } from './translations'; +import { PARTICIPANTS } from '../timeline/translations'; +import { NotePreviews } from '../open_timeline/note_previews'; +import type { TimelineResultNote } from '../open_timeline/types'; +import { getTimelineNoteSelector } from '../timeline/tabs/notes/selectors'; +import { getScrollToTopSelector } from '../timeline/tabs/selectors'; +import { useScrollToTop } from '../../../common/components/scroll_to_top'; +import { useUserPrivileges } from '../../../common/components/user_privileges'; +import { FullWidthFlexGroup, VerticalRule } from '../timeline/tabs/shared/layout'; + +const ScrollableDiv = styled.div` + overflow-x: hidden; + overflow-y: auto; + padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM}; + padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS}; +`; + +const StyledEuiFlexGroup = styled(EuiFlexGroup)` + flex: 0; +`; + +const Username = styled(EuiText)` + font-weight: bold; +`; + +interface UsernameWithAvatar { + username: string; +} + +const UsernameWithAvatarComponent: React.FC = ({ username }) => ( + + + + + + {username} + + +); + +const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); + +interface ParticipantsProps { + users: TimelineResultNote[]; +} + +export const ParticipantsComponent: React.FC = ({ users }) => { + const List = useMemo( + () => + users.map((user) => ( + + + + + )), + [users] + ); + + if (!users.length) { + return null; + } + + return ( + <> + +

{PARTICIPANTS}

+
+ + {List} + + ); +}; + +ParticipantsComponent.displayName = 'ParticipantsComponent'; + +const Participants = React.memo(ParticipantsComponent); + +interface NotesTabContentProps { + timelineId: string; +} + +/** + * Renders the "old" notes tab content. This should be removed when we remove the securitySolutionNotesEnabled feature flag + */ +export const OldNotes: React.FC = React.memo(({ timelineId }) => { + const dispatch = useDispatch(); + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + + const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); + const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); + + useScrollToTop('#scrollableNotes', !!scrollToTop); + + const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); + const { + createdBy, + eventIdToNoteIds, + noteIds, + status: timelineStatus, + } = useDeepEqualSelector((state) => getTimelineNotes(state, timelineId)); + const getNotesAsCommentsList = useMemo( + () => appSelectors.selectNotesAsCommentsListSelector(), + [] + ); + const [newNote, setNewNote] = useState(''); + const isImmutable = timelineStatus === TimelineStatusEnum.immutable; + const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); + + const allTimelineNoteIds = useMemo(() => { + const eventNoteIds = Object.values(eventIdToNoteIds).reduce( + (acc, v) => [...acc, ...v], + [] + ); + return [...noteIds, ...eventNoteIds]; + }, [noteIds, eventIdToNoteIds]); + + const notes = useMemo( + () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')), + [appNotes, allTimelineNoteIds] + ); + + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); + + const associateNote = useCallback( + (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), + [dispatch, timelineId] + ); + + const SidebarContent = useMemo( + () => ( + <> + {createdBy && ( + <> + +

{CREATED_BY}

+
+ + + + + )} + + + ), + [createdBy, participants] + ); + + return ( + + + + + {!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && ( + + )} + + + + {SidebarContent} + + + ); +}); + +OldNotes.displayName = 'OldNotes'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx new file mode 100644 index 0000000000000..270750e482a46 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { Participants } from './participants'; +import type { Note } from '../../../../common/api/timeline'; +import { + NOTE_AVATAR_WITH_NAME_TEST_ID, + NOTES_PARTICIPANTS_TITLE_TEST_ID, + TIMELINE_AVATAR_WITH_NAME_TEST_ID, + TIMELINE_PARTICIPANT_TITLE_TEST_ID, +} from './test_ids'; + +const mockNote: Note = { + eventId: '1', + noteId: '1', + note: 'note-1', + timelineId: 'timeline-1', + created: 1663882629000, + createdBy: 'elastic', + updated: 1663882629000, + updatedBy: 'elastic', + version: 'version', +}; +const notes: Note[] = [ + mockNote, + { + ...mockNote, + noteId: '2', + updatedBy: 'elastic', + }, + { + ...mockNote, + noteId: '3', + updatedBy: 'another-elastic', + }, +]; +const username = 'elastic'; + +describe('Participants', () => { + it('should render the timeline username and the unique notes users', () => { + const { getByTestId } = render(); + + expect(getByTestId(TIMELINE_PARTICIPANT_TITLE_TEST_ID)).toBeInTheDocument(); + + const timelineDescription = getByTestId(TIMELINE_AVATAR_WITH_NAME_TEST_ID); + expect(timelineDescription).toBeInTheDocument(); + expect(timelineDescription).toHaveTextContent(username); + + expect(getByTestId(NOTES_PARTICIPANTS_TITLE_TEST_ID)).toBeInTheDocument(); + + const firstNoteUser = getByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-0`); + expect(firstNoteUser).toBeInTheDocument(); + expect(firstNoteUser).toHaveTextContent(notes[0].updatedBy as string); + + const secondNoteUser = getByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-1`); + expect(secondNoteUser).toBeInTheDocument(); + expect(secondNoteUser).toHaveTextContent(notes[2].updatedBy as string); + }); + + it('should note render the timeline username if it is unavailable', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(TIMELINE_PARTICIPANT_TITLE_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should note render any note usernames if no notes have been created', () => { + const { queryByTestId } = render(); + + expect(queryByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-0`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx new file mode 100644 index 0000000000000..15662e0a0e6d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/participants.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { filter, uniqBy } from 'lodash/fp'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiTitle, + EuiHorizontalRule, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import React, { Fragment, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + NOTE_AVATAR_WITH_NAME_TEST_ID, + NOTES_PARTICIPANTS_TITLE_TEST_ID, + TIMELINE_AVATAR_WITH_NAME_TEST_ID, + TIMELINE_PARTICIPANT_TITLE_TEST_ID, +} from './test_ids'; +import { type Note } from '../../../../common/api/timeline'; + +export const PARTICIPANTS = i18n.translate( + 'xpack.securitySolution.timeline.notes.participantsTitle', + { + defaultMessage: 'Participants', + } +); +export const CREATED_BY = i18n.translate('xpack.securitySolution.timeline notes.createdByLabel', { + defaultMessage: 'Created by', +}); + +interface UsernameWithAvatar { + /** + * The username to display + */ + username: string; + /** + * Data test subject string for testing + */ + ['data-test-subj']?: string; +} + +/** + * Renders the username with an avatar + */ +const UsernameWithAvatar: React.FC = React.memo( + ({ username, 'data-test-subj': dataTestSubj }) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + {username} + + + + ); + } +); + +UsernameWithAvatar.displayName = 'UsernameWithAvatar'; + +interface ParticipantsProps { + /** + * The notes associated with the timeline + */ + notes: Note[]; + /** + * The user who created the timeline + */ + timelineCreatedBy: string | undefined; +} + +/** + * Renders all the users that are participating to the timeline + * - the user who created the timeline + * - all the unique users who created notes associated with the timeline + */ +export const Participants: React.FC = React.memo( + ({ notes, timelineCreatedBy }) => { + // filter for savedObjectId to make sure we don't display `elastic` user while saving the note + const participants = useMemo(() => uniqBy('updatedBy', filter('noteId', notes)), [notes]); + + return ( + <> + {timelineCreatedBy && ( + <> + +

{CREATED_BY}

+
+ + + + + )} + <> + +

{PARTICIPANTS}

+
+ + {participants.map((participant, index) => ( + + + + + ))} + + + ); + } +); + +Participants.displayName = 'Participants'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx new file mode 100644 index 0000000000000..7730424befee7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { SaveTimelineCallout } from './save_timeline'; +import { SAVE_TIMELINE_BUTTON_TEST_ID, SAVE_TIMELINE_CALLOUT_TEST_ID } from './test_ids'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../common/mock'; +import { TimelineId } from '../../../../common/types'; + +describe('SaveTimelineCallout', () => { + it('should render the callout and save components', () => { + const mockStore = createMockStore({ + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, + }); + + const { getByTestId, getByText, getAllByText } = render( + + + + ); + + expect(getByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + expect(getAllByText('Save timeline')).toHaveLength(2); + expect( + getByText('You need to save your timeline before creating notes for it.') + ).toBeInTheDocument(); + expect(getByTestId(SAVE_TIMELINE_BUTTON_TEST_ID)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx new file mode 100644 index 0000000000000..f31822561c54b --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/save_timeline.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { SAVE_TIMELINE_BUTTON_TEST_ID, SAVE_TIMELINE_CALLOUT_TEST_ID } from './test_ids'; +import { TimelineId } from '../../../../common/types'; +import { SaveTimelineButton } from '../modal/actions/save_timeline_button'; + +export const SAVE_TIMELINE_CALLOUT_TITLE = i18n.translate( + 'xpack.securitySolution.timeline.notes.saveTimeline.calloutTitle', + { + defaultMessage: 'Save timeline', + } +); +export const SAVE_TIMELINE_CALLOUT_CONTENT = i18n.translate( + 'xpack.securitySolution.timeline.notes.saveTimeline.calloutContent', + { + defaultMessage: 'You need to save your timeline before creating notes for it.', + } +); +export const SAVE_TIMELINE_BUTTON = i18n.translate( + 'xpack.securitySolution.flyout.left.notes.savedTimelineButtonLabel', + { + defaultMessage: 'Save timeline', + } +); + +/** + * Renders a callout to let the user know they have to save the timeline before creating notes + */ +export const SaveTimelineCallout = memo(() => { + return ( + + + + {SAVE_TIMELINE_CALLOUT_CONTENT} + + + + + + + ); +}); + +SaveTimelineCallout.displayName = 'SaveTimelineCallout'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts b/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts new file mode 100644 index 0000000000000..5e5637b9b321f --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/test_ids.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const PREFIX = 'timelineNotes'; + +export const SAVE_TIMELINE_CALLOUT_TEST_ID = `${PREFIX}SaveTimelineCallout` as const; +export const TIMELINE_PARTICIPANT_TITLE_TEST_ID = `${PREFIX}TimelineParticipantTitle` as const; +export const TIMELINE_AVATAR_WITH_NAME_TEST_ID = `${PREFIX}TimelineAvatarWithName` as const; +export const NOTES_PARTICIPANTS_TITLE_TEST_ID = `${PREFIX}NotesParticipantsTitle` as const; +export const NOTE_AVATAR_WITH_NAME_TEST_ID = `${PREFIX}NoteAvatarWithName` as const; +export const SAVE_TIMELINE_BUTTON_TEST_ID = `${PREFIX}SaveTimelineButton` as const; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx index 7f117d88bf531..7c1e7eeca37a3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/note_previews/index.test.tsx @@ -264,7 +264,7 @@ describe('NotePreviews', () => { } ); - expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="notes-toggle-event-details"]`).exists()).toBeTruthy(); }); test('should not render toggle event details action when showToggleEventDetailsAction is false ', () => { @@ -293,7 +293,7 @@ describe('NotePreviews', () => { } ); - expect(wrapper.find('[data-test-subj="notes-toggle-event-details"]').exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="notes-toggle-event-details"]`).exists()).toBeFalsy(); }); describe('Delete Notes', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx index 2401e0014fd8a..442f7548bcf73 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/index.tsx @@ -9,9 +9,11 @@ import { EuiBadge, EuiSkeletonText, EuiTabs, EuiTab } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import type { Ref, ReactElement, ComponentType } from 'react'; import React, { lazy, memo, Suspense, useCallback, useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import type { State } from '../../../../common/store'; import { useEsqlAvailability } from '../../../../common/hooks/esql/use_esql_availability'; import type { SessionViewConfig } from '../../../../../common/types'; import type { RowRenderer, TimelineId } from '../../../../../common/types/timeline'; @@ -38,7 +40,8 @@ import { import * as i18n from './translations'; import { useLicense } from '../../../../common/hooks/use_license'; import { initializeTimelineSettings } from '../../../store/actions'; -import { selectTimelineESQLSavedSearchId } from '../../../store/selectors'; +import { selectTimelineById, selectTimelineESQLSavedSearchId } from '../../../store/selectors'; +import { fetchNotesBySavedObjectIds, selectSortedNotesBySavedObjectId } from '../../../../notes'; const HideShowContainer = styled.div.attrs<{ $isVisible: boolean; isOverflowYScroll: boolean }>( ({ $isVisible = false, isOverflowYScroll = false }) => ({ @@ -248,6 +251,10 @@ const TabsContentComponent: React.FC = ({ selectTimelineESQLSavedSearchId(state, timelineId) ); + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); + const activeTab = useShallowEqualSelector((state) => getActiveTab(state, timelineId)); const showTimeline = useShallowEqualSelector((state) => getShowTimeline(state, timelineId)); const shouldShowESQLTab = useMemo(() => { @@ -273,6 +280,7 @@ const TabsContentComponent: React.FC = ({ const isEnterprisePlus = useLicense().isEnterprise(); + // old notes system (through timeline) const allTimelineNoteIds = useMemo(() => { const eventNoteIds = Object.values(eventIdToNoteIds).reduce( (acc, v) => [...acc, ...v], @@ -281,13 +289,43 @@ const TabsContentComponent: React.FC = ({ return [...globalTimelineNoteIds, ...eventNoteIds]; }, [globalTimelineNoteIds, eventIdToNoteIds]); - const numberOfNotes = useMemo( + const numberOfNotesOldSystem = useMemo( () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote.id)).length + (isEmpty(timelineDescription) ? 0 : 1), [appNotes, allTimelineNoteIds, timelineDescription] ); + const timeline = useSelector((state: State) => selectTimelineById(state, timelineId)); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] + ); + + // new note system + const fetchNotes = useCallback( + () => dispatch(fetchNotesBySavedObjectIds({ savedObjectIds: [timelineSavedObjectId] })), + [dispatch, timelineSavedObjectId] + ); + useEffect(() => { + if (isTimelineSaved) { + fetchNotes(); + } + }, [fetchNotes, isTimelineSaved]); + + const numberOfNotesNewSystem = useSelector((state: State) => + selectSortedNotesBySavedObjectId(state, { + savedObjectId: timelineSavedObjectId, + sort: { field: 'created', direction: 'asc' }, + }) + ); + + const numberOfNotes = useMemo( + () => (securitySolutionNotesEnabled ? numberOfNotesNewSystem.length : numberOfNotesOldSystem), + [numberOfNotesNewSystem, numberOfNotesOldSystem, securitySolutionNotesEnabled] + ); + const setActiveTab = useCallback( (tab: TimelineTabs) => { dispatch(timelineActions.setActiveTabTimeline({ id: timelineId, activeTab: tab })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx new file mode 100644 index 0000000000000..e70bd5946e3b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import NotesTabContentComponent, { FETCH_NOTES_ERROR, NO_NOTES } from '.'; +import { render } from '@testing-library/react'; +import { createMockStore, mockGlobalState, TestProviders } from '../../../../../common/mock'; +import { ReqStatus } from '../../../../../notes'; +import { + NOTES_LOADING_TEST_ID, + TIMELINE_DESCRIPTION_COMMENT_TEST_ID, +} from '../../../../../notes/components/test_ids'; +import React from 'react'; +import { TimelineId } from '../../../../../../common/types'; +import { SAVE_TIMELINE_CALLOUT_TEST_ID } from '../../../notes/test_ids'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; + +jest.mock('../../../../../common/hooks/use_experimental_features'); +jest.mock('../../../../../common/components/user_privileges'); + +const mockAddError = jest.fn(); +jest.mock('../../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addError: mockAddError, + }), +})); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const mockGlobalStateWithSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + savedObjectId: 'savedObjectId', + }, + }, + }, +}; +const mockGlobalStateWithUnSavedTimeline = { + ...mockGlobalState, + timeline: { + ...mockGlobalState.timeline, + timelineById: { + ...mockGlobalState.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalState.timeline.timelineById[TimelineId.test], + }, + }, + }, +}; + +describe('NotesTabContentComponent', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + (useUserPrivileges as jest.Mock).mockReturnValue({ + kibanaSecuritySolutionsPrivileges: { crud: true }, + }); + }); + + it('should show the old note system', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('old-notes-screen')).toBeInTheDocument(); + expect(queryByTestId('new-notes-screen')).not.toBeInTheDocument(); + }); + + it('should show the new note system', () => { + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('new-notes-screen')).toBeInTheDocument(); + expect(queryByTestId('old-notes-screen')).not.toBeInTheDocument(); + }); + + it('should fetch notes for the saved object id if timeline has been saved and hide callout', () => { + const mockStore = createMockStore(mockGlobalStateWithSavedTimeline); + + const { queryByTestId } = render( + + + + ); + + expect(mockDispatch).toHaveBeenCalled(); + expect(queryByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should not fetch notes if timeline is unsaved', () => { + const mockStore = createMockStore(mockGlobalStateWithUnSavedTimeline); + + const { getByTestId } = render( + + + + ); + + expect(mockDispatch).not.toHaveBeenCalled(); + expect(getByTestId(SAVE_TIMELINE_CALLOUT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render loading spinner if notes are being fetched', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Loading, + }, + }, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should render no data message if no notes are present and timeline has been saved', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Succeeded, + }, + }, + }); + + const { getByText } = render( + + + + ); + + expect(getByText(NO_NOTES)).toBeInTheDocument(); + }); + + it('should render error toast if fetching notes fails', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + notes: { + ...mockGlobalStateWithSavedTimeline.notes, + status: { + ...mockGlobalStateWithSavedTimeline.notes.status, + fetchNotesBySavedObjectIds: ReqStatus.Failed, + }, + error: { + ...mockGlobalStateWithSavedTimeline.notes.error, + fetchNotesBySavedObjectIds: { type: 'http', status: 500 }, + }, + }, + }); + + render( + + + + ); + + expect(mockAddError).toHaveBeenCalledWith(null, { + title: FETCH_NOTES_ERROR, + }); + }); + + it('should render the timeline description at the top', () => { + const mockStore = createMockStore({ + ...mockGlobalStateWithSavedTimeline, + timeline: { + ...mockGlobalStateWithSavedTimeline.timeline, + timelineById: { + ...mockGlobalStateWithSavedTimeline.timeline.timelineById, + [TimelineId.active]: { + ...mockGlobalStateWithSavedTimeline.timeline.timelineById[TimelineId.active], + description: 'description', + }, + }, + }, + }); + + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId(TIMELINE_DESCRIPTION_COMMENT_TEST_ID)).toBeInTheDocument(); + expect(getByText('description')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx index 27d82f01828fe..959581a241764 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/notes/index.tsx @@ -5,214 +5,212 @@ * 2.0. */ -import { filter, uniqBy } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { EuiAvatar, + EuiComment, EuiFlexGroup, EuiFlexItem, + EuiLoadingElastic, + EuiPanel, EuiSpacer, EuiText, EuiTitle, - EuiPanel, - EuiHorizontalRule, } from '@elastic/eui'; - -import React, { Fragment, useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import styled from 'styled-components'; - -import type { EuiTheme } from '@kbn/react-kibana-context-styled'; -import { timelineActions } from '../../../../store'; +import { css } from '@emotion/react'; +import { useDispatch, useSelector } from 'react-redux'; +import { FormattedRelative } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { SaveTimelineCallout } from '../../../notes/save_timeline'; +import { AddNote } from '../../../../../notes/components/add_note'; +import { useUserPrivileges } from '../../../../../common/components/user_privileges'; +import { + NOTES_LOADING_TEST_ID, + TIMELINE_DESCRIPTION_COMMENT_TEST_ID, +} from '../../../../../notes/components/test_ids'; +import { useAppToasts } from '../../../../../common/hooks/use_app_toasts'; +import { ADDED_A_DESCRIPTION } from '../../../open_timeline/note_previews/translations'; +import { defaultToEmptyTag, getEmptyValue } from '../../../../../common/components/empty_value'; +import { selectTimelineById } from '../../../../store/selectors'; import { - useDeepEqualSelector, - useShallowEqualSelector, -} from '../../../../../common/hooks/use_selector'; -import { TimelineStatusEnum } from '../../../../../../common/api/timeline'; -import { appSelectors } from '../../../../../common/store/app'; -import { AddNote } from '../../../notes/add_note'; -import { CREATED_BY, NOTES } from '../../../notes/translations'; -import { PARTICIPANTS } from '../../translations'; -import { NotePreviews } from '../../../open_timeline/note_previews'; -import type { TimelineResultNote } from '../../../open_timeline/types'; -import { getTimelineNoteSelector } from './selectors'; + fetchNotesBySavedObjectIds, + ReqStatus, + selectFetchNotesBySavedObjectIdsError, + selectFetchNotesBySavedObjectIdsStatus, + selectSortedNotesBySavedObjectId, +} from '../../../../../notes'; +import type { Note } from '../../../../../../common/api/timeline'; +import { NotesList } from '../../../../../notes/components/notes_list'; +import { OldNotes } from '../../../notes/old_notes'; +import { Participants } from '../../../notes/participants'; +import { NOTES } from '../../../notes/translations'; +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector'; import { getScrollToTopSelector } from '../selectors'; import { useScrollToTop } from '../../../../../common/components/scroll_to_top'; -import { useUserPrivileges } from '../../../../../common/components/user_privileges'; -import { FullWidthFlexGroup, VerticalRule } from '../shared/layout'; - -const ScrollableDiv = styled.div` - overflow-x: hidden; - overflow-y: auto; - padding-inline: ${({ theme }) => (theme as EuiTheme).eui.euiSizeM}; - padding-block: ${({ theme }) => (theme as EuiTheme).eui.euiSizeS}; -`; - -const StyledPanel = styled(EuiPanel)` - border: 0; - box-shadow: none; -`; - -const StyledEuiFlexGroup = styled(EuiFlexGroup)` - flex: 0; -`; - -const Username = styled(EuiText)` - font-weight: bold; -`; - -interface UsernameWithAvatar { - username: string; -} +import type { State } from '../../../../../common/store'; -const UsernameWithAvatarComponent: React.FC = ({ username }) => ( - - - - - - {username} - - -); - -const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent); - -interface ParticipantsProps { - users: TimelineResultNote[]; -} - -const ParticipantsComponent: React.FC = ({ users }) => { - const List = useMemo( - () => - users.map((user) => ( - - - - - )), - [users] - ); - - if (!users.length) { - return null; +export const FETCH_NOTES_ERROR = i18n.translate( + 'xpack.securitySolution.notes.fetchNotesErrorLabel', + { + defaultMessage: 'Error fetching notes', } - - return ( - <> - -

{PARTICIPANTS}

-
- - {List} - - ); -}; - -ParticipantsComponent.displayName = 'ParticipantsComponent'; - -const Participants = React.memo(ParticipantsComponent); +); +export const NO_NOTES = i18n.translate('xpack.securitySolution.notes.noNotesLabel', { + defaultMessage: 'No notes have yet been created for this timeline', +}); interface NotesTabContentProps { + /** + * The timeline id + */ timelineId: string; } -const NotesTabContentComponent: React.FC = ({ timelineId }) => { +/** + * Renders the notes tab content. + * At this time the component support the old notes system and the new notes system (via the securitySolutionNotesEnabled feature flag). + * The old notes system is deprecated and will be removed in the future. + * In both cases, the component fetches the notes for the timeline and renders: + * - the timeline description + * - the notes list + * - the participants list + * - the markdown to create a new note and the add note button + */ +const NotesTabContentComponent: React.FC = React.memo(({ timelineId }) => { + const { addError: addErrorToast } = useAppToasts(); const dispatch = useDispatch(); + const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + const canCreateNotes = kibanaSecuritySolutionsPrivileges.crud; + + const securitySolutionNotesEnabled = useIsExperimentalFeatureEnabled( + 'securitySolutionNotesEnabled' + ); const getScrollToTop = useMemo(() => getScrollToTopSelector(), []); const scrollToTop = useShallowEqualSelector((state) => getScrollToTop(state, timelineId)); - useScrollToTop('#scrollableNotes', !!scrollToTop); - const getTimelineNotes = useMemo(() => getTimelineNoteSelector(), []); - const { - createdBy, - eventIdToNoteIds, - noteIds, - status: timelineStatus, - } = useDeepEqualSelector((state) => getTimelineNotes(state, timelineId)); - const getNotesAsCommentsList = useMemo( - () => appSelectors.selectNotesAsCommentsListSelector(), - [] + const timeline = useSelector((state: State) => selectTimelineById(state, timelineId)); + const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]); + const isTimelineSaved: boolean = useMemo( + () => timelineSavedObjectId.length > 0, + [timelineSavedObjectId] ); - const [newNote, setNewNote] = useState(''); - const isImmutable = timelineStatus === TimelineStatusEnum.immutable; - const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList); - - const allTimelineNoteIds = useMemo(() => { - const eventNoteIds = Object.values(eventIdToNoteIds).reduce( - (acc, v) => [...acc, ...v], - [] - ); - return [...noteIds, ...eventNoteIds]; - }, [noteIds, eventIdToNoteIds]); - const notes = useMemo( - () => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')), - [appNotes, allTimelineNoteIds] + const fetchNotes = useCallback( + () => dispatch(fetchNotesBySavedObjectIds({ savedObjectIds: [timelineSavedObjectId] })), + [dispatch, timelineSavedObjectId] ); - // filter for savedObjectId to make sure we don't display `elastic` user while saving the note - const participants = useMemo(() => uniqBy('updatedBy', filter('savedObjectId', notes)), [notes]); - - const associateNote = useCallback( - (noteId: string) => dispatch(timelineActions.addNote({ id: timelineId, noteId })), - [dispatch, timelineId] + useEffect(() => { + if (isTimelineSaved) { + fetchNotes(); + } + }, [fetchNotes, isTimelineSaved]); + + const notes: Note[] = useSelector((state: State) => + selectSortedNotesBySavedObjectId(state, { + savedObjectId: timelineSavedObjectId, + sort: { field: 'created', direction: 'asc' }, + }) ); - - const SidebarContent = useMemo( - () => ( + const fetchStatus = useSelector((state: State) => selectFetchNotesBySavedObjectIdsStatus(state)); + const fetchError = useSelector((state: State) => selectFetchNotesBySavedObjectIdsError(state)); + + // show a toast if the fetch notes call fails + useEffect(() => { + if (fetchStatus === ReqStatus.Failed && fetchError) { + addErrorToast(null, { + title: FETCH_NOTES_ERROR, + }); + } + }, [addErrorToast, fetchError, fetchStatus]); + + // if timeline was saved with a description, we show it at the very top of the notes tab + const timelineDescription = useMemo(() => { + if (!timeline?.description) { + return null; + } + + return ( <> - {createdBy && ( - <> - -

{CREATED_BY}

-
- - - - - )} - + + {timeline.updated ? ( + + ) : ( + getEmptyValue() + )} + + } + event={ADDED_A_DESCRIPTION} + timelineAvatar={} + data-test-subj={TIMELINE_DESCRIPTION_COMMENT_TEST_ID} + > + {timeline.description} + + - ), - [createdBy, participants] - ); + ); + }, [timeline.description, timeline.updated, timeline.updatedBy]); return ( - - - + + +

{NOTES}

- - - {!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && ( - +
+ + {securitySolutionNotesEnabled ? ( + + + {timelineDescription} + {fetchStatus === ReqStatus.Loading && ( + + )} + {isTimelineSaved && fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? ( + + +

{NO_NOTES}

+
+
+ ) : ( + + )} + {canCreateNotes && ( + <> + + + {!isTimelineSaved && } + + + )} +
+ + + +
+ ) : ( + )} -
-
- - - {SidebarContent} - -
+ + + ); -}; +}); NotesTabContentComponent.displayName = 'NotesTabContentComponent'; -const NotesTabContent = React.memo(NotesTabContentComponent); - // eslint-disable-next-line import/no-default-export -export { NotesTabContent as default }; +export { NotesTabContentComponent as default }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx index 7e5e9a221ffee..2fa1b53881b08 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/query/query_tab_unified_components.test.tsx @@ -37,6 +37,7 @@ import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/com import * as timelineActions from '../../../../store/actions'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { createExpandableFlyoutApiMock } from '../../../../../common/mock/expandable_flyout'; +import { OPEN_FLYOUT_BUTTON_TEST_ID } from '../../../../../notes/components/test_ids'; jest.mock('../../../../../common/components/user_privileges'); @@ -1004,7 +1005,7 @@ describe('query tab with unified timeline', () => { fireEvent.click(screen.getByTestId('timeline-notes-button-small')); await waitFor(() => { - expect(screen.queryByTestId('notes-toggle-event-details')).not.toBeInTheDocument(); + expect(screen.queryByTestId(OPEN_FLYOUT_BUTTON_TEST_ID)).not.toBeInTheDocument(); }); }, SPECIAL_TEST_TIMEOUT diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts index 30ae41c1da820..4fd39ade7df0c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { v4 as uuidv4 } from 'uuid'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import { serverMock, @@ -23,20 +24,20 @@ const getAllNotesRequest = (query?: GetNotesRequestQuery) => query, }); -const createMockedNotes = (numberOfNotes: number) => { - return Array.from({ length: numberOfNotes }, (_, index) => { - return { - id: index + 1, - timelineId: 'timeline', - eventId: 'event', - note: `test note ${index}`, - created: 1280120812453, - createdBy: 'test', - updated: 108712801280, - updatedBy: 'test', - }; - }); -}; +const createMockedNotes = ( + numberOfNotes: number, + options?: { documentId?: string; savedObjectId?: string } +) => + Array.from({ length: numberOfNotes }, () => ({ + id: uuidv4(), + timelineId: options?.savedObjectId || 'timeline', + eventId: options?.documentId || 'event', + note: `test note`, + created: 1280120812453, + createdBy: 'test', + updated: 108712801280, + updatedBy: 'test', + })); describe('get notes route', () => { let server: ReturnType; @@ -45,7 +46,7 @@ describe('get notes route', () => { let mockGetAllSavedNote: jest.Mock; beforeEach(() => { - jest.clearAllMocks(); + jest.resetModules(); server = serverMock.create(); context = requestContextMock.createTools().context; @@ -61,14 +62,16 @@ describe('get notes route', () => { jest.doMock('../../saved_object/notes', () => ({ getAllSavedNote: mockGetAllSavedNote, })); + const getNotesRoute = jest.requireActual('.').getNotesRoute; getNotesRoute(server.router, createMockConfig(), securitySetup); }); test('should return a list of notes and the count by default', async () => { + const mockNotes = createMockedNotes(3); mockGetAllSavedNote.mockResolvedValue({ - notes: createMockedNotes(5), - totalCount: 5, + notes: mockNotes, + totalCount: mockNotes.length, }); const response = await server.inject( @@ -78,8 +81,88 @@ describe('get notes route', () => { expect(response.status).toEqual(200); expect(response.body).toEqual({ - totalCount: 5, - notes: createMockedNotes(5), + notes: mockNotes, + totalCount: mockNotes.length, + }); + }); + + test('should return a list of notes filtered by an array of document ids', async () => { + const documentId = 'document1'; + const mockDocumentNotes = createMockedNotes(3, { documentId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ documentIds: [documentId] }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + }); + + test('should return a list of notes filtered by a single document id', async () => { + const documentId = 'document2'; + const mockDocumentNotes = createMockedNotes(3, { documentId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ documentIds: documentId }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockDocumentNotes, + totalCount: mockDocumentNotes.length, + }); + }); + + test('should return a list of notes filtered by an array of saved object ids', async () => { + const savedObjectId = 'savedObject1'; + const mockSavedObjectIdNotes = createMockedNotes(3, { savedObjectId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ savedObjectIds: [savedObjectId] }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + }); + + test('should return a list of notes filtered by a single saved object id', async () => { + const savedObjectId = 'savedObject2'; + const mockSavedObjectIdNotes = createMockedNotes(3, { savedObjectId }); + mockGetAllSavedNote.mockResolvedValue({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, + }); + + const response = await server.inject( + getAllNotesRequest({ savedObjectIds: savedObjectId }), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + notes: mockSavedObjectIdNotes, + totalCount: mockSavedObjectIdNotes.length, }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 920a7ef763dd5..2794fd5d8cd7d 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -9,6 +9,7 @@ import type { IKibanaResponse } from '@kbn/core-http-server'; import { transformError } from '@kbn/securitysolution-es-utils'; import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers'; +import { timelineSavedObjectType } from '../../saved_object_mappings'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { NOTE_URL } from '../../../../../common/constants'; @@ -39,6 +40,7 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { const queryParams = request.query; const frameworkRequest = await buildFrameworkRequest(context, request); const documentIds = queryParams.documentIds ?? null; + const savedObjectIds = queryParams.savedObjectIds ?? null; if (documentIds != null) { if (Array.isArray(documentIds)) { const docIdSearchString = documentIds?.join(' | '); @@ -61,6 +63,34 @@ export const getNotesRoute = (router: SecuritySolutionPluginRouter) => { const res = await getAllSavedNote(frameworkRequest, options); return response.ok({ body: res ?? {} }); } + } else if (savedObjectIds != null) { + if (Array.isArray(savedObjectIds)) { + const soIdSearchString = savedObjectIds?.join(' | '); + const options = { + type: noteSavedObjectType, + hasReference: { + type: timelineSavedObjectType, + id: soIdSearchString, + }, + page: 1, + perPage: MAX_UNASSOCIATED_NOTES, + }; + const res = await getAllSavedNote(frameworkRequest, options); + const body: GetNotesResponse = res ?? {}; + return response.ok({ body }); + } else { + const options = { + type: noteSavedObjectType, + hasReference: { + type: timelineSavedObjectType, + id: savedObjectIds, + }, + perPage: MAX_UNASSOCIATED_NOTES, + }; + const res = await getAllSavedNote(frameworkRequest, options); + const body: GetNotesResponse = res ?? {}; + return response.ok({ body }); + } } else { const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1;