[8.x] [Security Solution][Notes] - fetch notes by saved object ids (#193930) (#194643)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][Notes] - fetch notes by saved object ids
(#193930)](https://github.com/elastic/kibana/pull/193930)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Philippe
Oberti","email":"philippe.oberti@elastic.co"},"sourceCommit":{"committedDate":"2024-10-01T20:44:41Z","message":"[Security
Solution][Notes] - fetch notes by saved object ids
(#193930)","sha":"ca46f784e5185bbce503171e6432e960c94f2586","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport","release_note:skip","v9.0.0","Team:Threat
Hunting:Investigations","v8.16.0"],"title":"[Security Solution][Notes] -
fetch notes by saved object
ids","number":193930,"url":"https://github.com/elastic/kibana/pull/193930","mergeCommit":{"message":"[Security
Solution][Notes] - fetch notes by saved object ids
(#193930)","sha":"ca46f784e5185bbce503171e6432e960c94f2586"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/193930","number":193930,"mergeCommit":{"message":"[Security
Solution][Notes] - fetch notes by saved object ids
(#193930)","sha":"ca46f784e5185bbce503171e6432e960c94f2586"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: Philippe Oberti <philippe.oberti@elastic.co>
This commit is contained in:
Kibana Machine 2024-10-02 08:27:19 +10:00 committed by GitHub
parent 29c4ccefd5
commit 17dfc64aa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 2891 additions and 1070 deletions

View file

@ -14806,6 +14806,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:
@ -31543,6 +31547,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

View file

@ -18236,6 +18236,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:
@ -39552,6 +39556,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

View file

@ -21,6 +21,9 @@ import { Note } from '../model/components.gen';
export type DocumentIds = z.infer<typeof DocumentIds>;
export const DocumentIds = z.union([z.array(z.string()), z.string()]);
export type SavedObjectIds = z.infer<typeof SavedObjectIds>;
export const SavedObjectIds = z.union([z.array(z.string()), z.string()]);
export type GetNotesResult = z.infer<typeof GetNotesResult>;
export const GetNotesResult = z.object({
totalCount: z.number(),
@ -30,6 +33,7 @@ export const GetNotesResult = z.object({
export type GetNotesRequestQuery = z.infer<typeof GetNotesRequestQuery>;
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(),

View file

@ -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]

View file

@ -62,6 +62,10 @@ paths:
name: documentIds
schema:
$ref: '#/components/schemas/DocumentIds'
- in: query
name: savedObjectIds
schema:
$ref: '#/components/schemas/SavedObjectIds'
- in: query
name: page
schema:
@ -1346,6 +1350,12 @@ components:
- threat_match
- zeek
type: string
SavedObjectIds:
oneOf:
- items:
type: string
type: array
- type: string
SavedObjectResolveAliasPurpose:
enum:
- savedObjectConversion

View file

@ -62,6 +62,10 @@ paths:
name: documentIds
schema:
$ref: '#/components/schemas/DocumentIds'
- in: query
name: savedObjectIds
schema:
$ref: '#/components/schemas/SavedObjectIds'
- in: query
name: page
schema:
@ -1346,6 +1350,12 @@ components:
- threat_match
- zeek
type: string
SavedObjectIds:
oneOf:
- items:
type: string
type: array
- type: string
SavedObjectResolveAliasPurpose:
enum:
- savedObjectConversion

View file

@ -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,

View file

@ -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<boolean>(true);
const onCheckboxChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => 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 (
<>
<EuiCommentList>
<EuiComment username="">
<MarkdownEditor
dataTestSubj={ADD_NOTE_MARKDOWN_TEST_ID}
value={editorValue}
onChange={setEditorValue}
ariaLabel={MARKDOWN_ARIA_LABEL}
setIsMarkdownInvalid={setIsMarkdownInvalid}
/>
</EuiComment>
</EuiCommentList>
<EuiSpacer />
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}>
<EuiFlexItem grow={false}>
<>
<EuiCheckbox
data-test-subj={ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID}
id={timelineCheckBoxId}
label={
<>
{ATTACH_TO_TIMELINE_CHECKBOX}
<EuiToolTip position="top" content={ATTACH_TO_TIMELINE_INFO}>
<EuiIcon
type="iInCircle"
css={css`
margin-left: ${euiTheme.size.s};
`}
/>
</EuiToolTip>
</>
}
disabled={checkBoxDisabled}
checked={initialCheckboxChecked && checked}
onChange={(e) => onCheckboxChange(e)}
/>
</>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={addNote}
isLoading={createStatus === ReqStatus.Loading}
disabled={buttonDisabled}
data-test-subj={ADD_NOTE_BUTTON_TEST_ID}
>
{ADD_NOTE_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
});
AddNote.displayName = 'AddNote';

View file

@ -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(
<TestProviders store={mockStore}>
<AttachToActiveTimeline
setAttachToTimeline={mockSetAttachToTimeline}
isCheckboxDisabled={false}
/>
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<AttachToActiveTimeline
setAttachToTimeline={mockSetAttachToTimeline}
isCheckboxDisabled={false}
/>
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<AttachToActiveTimeline
setAttachToTimeline={mockSetAttachToTimeline}
isCheckboxDisabled={false}
/>
</TestProviders>
);
const checkbox = getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID);
checkbox.click();
expect(mockSetAttachToTimeline).toHaveBeenCalledWith(false);
checkbox.click();
expect(mockSetAttachToTimeline).toHaveBeenCalledWith(true);
});
});

View file

@ -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<boolean>(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<HTMLInputElement>) => {
setChecked(e.target.checked);
setAttachToTimeline(e.target.checked);
},
[setAttachToTimeline]
);
return (
<EuiCallOut
title={ATTACH_TO_TIMELINE_CALLOUT_TITLE}
color={'primary'}
iconType="iInCircle"
data-test-subj={ATTACH_TO_TIMELINE_CALLOUT_TEST_ID}
css={css`
margin-left: 50px;
`}
>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem>
<EuiText size="s">
{isTimelineSaved ? SAVED_TIMELINE_CALLOUT_CONTENT : UNSAVED_TIMELINE_CALLOUT_CONTENT}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{isTimelineSaved ? (
<EuiCheckbox
data-test-subj={ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID}
id={timelineCheckBoxId}
label={ATTACH_TO_TIMELINE_CHECKBOX}
disabled={isCheckboxDisabled}
checked={checked}
onChange={(e) => onCheckboxChange(e)}
/>
) : (
<SaveTimelineButton
timelineId={TimelineId.active}
buttonText={SAVE_TIMELINE_BUTTON}
data-test-subj={SAVE_TIMELINE_BUTTON_TEST_ID}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
}
);
AttachToActiveTimeline.displayName = 'AttachToActiveTimeline';

View file

@ -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(
<TestProviders store={mockStore}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
</TestProviders>
);
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(
<TestProviders store={store}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
</TestProviders>
);
expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should not render an add note button for users without crud privileges', () => {
useUserPrivilegesMock.mockReturnValue({
kibanaSecuritySolutionsPrivileges: { crud: false, read: true },
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(
<TestProviders store={store}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
</TestProviders>
);
expect(getByText(NO_NOTES)).toBeInTheDocument();
});
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(
<TestProviders store={store}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
</TestProviders>
);
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 },
});
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();
});
});

View file

@ -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<boolean>(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 (
<>
<NotesList eventId={eventId} />
{fetchStatus === ReqStatus.Loading && (
<EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" />
)}
{fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<p>{NO_NOTES}</p>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<NotesList notes={notes} options={{ hideFlyoutIcon: true }} />
)}
{canCreateNotes && (
<>
<EuiSpacer />
<AddNote eventId={eventId} />
<AddNote
eventId={eventId}
timelineId={isTimelineFlyout && attachToTimeline ? timelineSavedObjectId : ''}
>
{isTimelineFlyout && (
<AttachToActiveTimeline
setAttachToTimeline={setAttachToTimeline}
isCheckboxDisabled={timelineSavedObjectId.length === 0}
/>
)}
</AddNote>
</>
)}
</>

View file

@ -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(
<TestProviders>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'wrong-event-id'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList eventId={'1'} />
</TestProviders>
);
expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument();
});
});

View file

@ -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 <EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" />;
}
if (fetchStatus === ReqStatus.Succeeded && notes.length === 0) {
return <p>{NO_NOTES}</p>;
}
return (
<EuiCommentList>
{notes.map((note, index) => (
<EuiComment
data-test-subj={`${NOTES_COMMENT_TEST_ID}-${index}`}
key={note.noteId}
username={note.createdBy}
timestamp={<>{note.created && <FormattedRelative value={new Date(note.created)} />}</>}
event={ADDED_A_NOTE}
actions={
<>
{note.timelineId && note.timelineId.length > 0 && (
<EuiButtonIcon
data-test-subj={`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`}
title="Open timeline"
aria-label="Open timeline"
color="text"
iconType="timeline"
onClick={() => openTimeline(note)}
/>
)}
{canDeleteNotes && (
<EuiButtonIcon
data-test-subj={`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`}
title={DELETE_NOTE}
aria-label={DELETE_NOTE}
color="text"
iconType="trash"
onClick={() => deleteNoteFc(note.noteId)}
disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading}
isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading}
/>
)}
</>
}
timelineAvatar={
<EuiAvatar
data-test-subj={`${NOTE_AVATAR_TEST_ID}-${index}`}
size="l"
name={note.updatedBy || '?'}
/>
}
>
<MarkdownRenderer>{note.note || ''}</MarkdownRenderer>
</EuiComment>
))}
{createStatus === ReqStatus.Loading && (
<EuiLoadingElastic size="xxl" data-test-subj={ADD_NOTE_LOADING_TEST_ID} />
)}
</EuiCommentList>
);
});
NotesList.displayName = 'NotesList';

View file

@ -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;

View file

@ -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
*/

View file

@ -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(
<TestProviders>
<AddNote eventId={'event-id'} disableButton={true} />
</TestProviders>
);
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(
<TestProviders store={store}>
<AddNote eventId={'event-id'} />
</TestProviders>
);
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(
<TestProviders store={store}>
<AddNote eventId={'event-id'} />
</TestProviders>
);
expect(getByTestId(ATTACH_TO_TIMELINE_CHECKBOX_TEST_ID)).not.toHaveAttribute('disabled');
});
});

View file

@ -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 (
<>
<EuiCommentList>
<EuiComment username="">
<MarkdownEditor
dataTestSubj={ADD_NOTE_MARKDOWN_TEST_ID}
value={editorValue}
onChange={setEditorValue}
ariaLabel={MARKDOWN_ARIA_LABEL}
setIsMarkdownInvalid={setIsMarkdownInvalid}
/>
</EuiComment>
</EuiCommentList>
<EuiSpacer size="m" />
{children && (
<>
{children}
<EuiSpacer size="m" />
</>
)}
<EuiFlexGroup alignItems="center" justifyContent="flexEnd" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButton
onClick={addNote}
isLoading={createStatus === ReqStatus.Loading}
disabled={buttonDisabled}
data-test-subj={ADD_NOTE_BUTTON_TEST_ID}
>
{ADD_NOTE_BUTTON}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
}
);
AddNote.displayName = 'AddNote';

View file

@ -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(
<TestProviders>
<DeleteNoteButtonIcon note={note} index={index} />
</TestProviders>
);
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(
<TestProviders store={store}>
<DeleteNoteButtonIcon note={note} index={index} />
</TestProviders>
);
expect(getByTestId(`${DELETE_NOTE_BUTTON_TEST_ID}-0`)).toHaveAttribute('disabled');
});
it('should dispatch delete action when user deletes a new note', () => {
const { getByTestId } = render(
<TestProviders>
<DeleteNoteButtonIcon note={note} index={index} />
</TestProviders>
);
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(
<TestProviders store={store}>
<DeleteNoteButtonIcon note={note} index={index} />
</TestProviders>
);
expect(mockAddError).toHaveBeenCalledWith(null, {
title: DELETE_NOTE_ERROR,
});
});
});

View file

@ -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 (
<EuiButtonIcon
data-test-subj={`${DELETE_NOTE_BUTTON_TEST_ID}-${index}`}
title={DELETE_NOTE}
aria-label={DELETE_NOTE}
color="text"
iconType="trash"
onClick={() => deleteNoteFc(note.noteId)}
disabled={deletingNoteId !== note.noteId && deleteStatus === ReqStatus.Loading}
isLoading={deletingNoteId === note.noteId && deleteStatus === ReqStatus.Loading}
/>
);
});
DeleteNoteButtonIcon.displayName = 'DeleteNoteButtonIcon';

View file

@ -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(
<TestProviders>
<NotesList notes={[mockNote]} />
</TestProviders>
);
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(
<TestProviders>
<NotesList notes={customMockNotes} />
</TestProviders>
);
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(
<TestProviders store={store}>
<NotesList notes={[mockNote]} />
</TestProviders>
);
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(
<TestProviders>
<NotesList notes={[mockNote]} />
</TestProviders>
);
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(
<TestProviders>
<NotesList notes={customMockNotes} />
</TestProviders>
);
expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument();
});
it('should not render timeline icon if it should be hidden', () => {
const { queryByTestId } = render(
<TestProviders>
<NotesList notes={[mockNote]} options={mockOptions} />
</TestProviders>
);
expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument();
});
});

View file

@ -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 (
<EuiCommentList>
{notes.map((note, index) => (
<EuiComment
data-test-subj={`${NOTES_COMMENT_TEST_ID}-${index}`}
key={note.noteId}
username={note.createdBy}
timestamp={<>{note.created && <FormattedRelative value={new Date(note.created)} />}</>}
event={ADDED_A_NOTE}
actions={
<>
{note.eventId && !options?.hideFlyoutIcon && (
<OpenFlyoutButtonIcon eventId={note.eventId} timelineId={note.timelineId} />
)}
{note.timelineId && note.timelineId.length > 0 && !options?.hideTimelineIcon && (
<OpenTimelineButtonIcon note={note} index={index} />
)}
{canDeleteNotes && <DeleteNoteButtonIcon note={note} index={index} />}
</>
}
timelineAvatar={
<EuiAvatar
data-test-subj={`${NOTE_AVATAR_TEST_ID}-${index}`}
size="l"
name={note.updatedBy || '?'}
/>
}
>
<MarkdownRenderer>{note.note || ''}</MarkdownRenderer>
</EuiComment>
))}
{createStatus === ReqStatus.Loading && (
<EuiLoadingElastic size="xxl" data-test-subj={ADD_NOTE_LOADING_TEST_ID} />
)}
</EuiCommentList>
);
});
NotesList.displayName = 'NotesList';

View file

@ -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(
<TestProviders>
<OpenFlyoutButtonIcon eventId={mockEventId} timelineId={mockTimelineId} />
</TestProviders>
);
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(
<TestProviders>
<OpenFlyoutButtonIcon eventId={mockEventId} timelineId={mockTimelineId} />
</TestProviders>
);
const button = getByTestId(OPEN_FLYOUT_BUTTON_TEST_ID);
button.click();
expect(openFlyout).toHaveBeenCalledWith({
right: {
id: DocumentDetailsRightPanelKey,
params: {
id: mockEventId,
indexName: 'test1,test2',
scopeId: mockTimelineId,
},
},
});
});
});

View file

@ -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 (
<EuiButtonIcon
data-test-subj={OPEN_FLYOUT_BUTTON_TEST_ID}
title={OPEN_FLYOUT_BUTTON}
aria-label={OPEN_FLYOUT_BUTTON}
color="text"
iconType="arrowRight"
onClick={handleClick}
/>
);
});
OpenFlyoutButtonIcon.displayName = 'OpenFlyoutButtonIcon';

View file

@ -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(<OpenTimelineButtonIcon note={note} index={index} />);
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(<OpenTimelineButtonIcon note={note} index={index} />);
const button = getByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`);
button.click();
expect(openTimeline).toHaveBeenCalledWith({
duplicate: false,
onOpenTimeline: undefined,
timelineId: note.timelineId,
timelineType: undefined,
unifiedComponentsInTimelineDisabled,
});
});
});

View file

@ -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 (
<EuiButtonIcon
data-test-subj={`${OPEN_TIMELINE_BUTTON_TEST_ID}-${index}`}
title="Open timeline"
aria-label="Open timeline"
color="text"
iconType="timeline"
onClick={() => openTimeline(note)}
/>
);
});
OpenTimelineButtonIcon.displayName = 'OpenTimelineButtonIcon';

View file

@ -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;

View file

@ -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,

View file

@ -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<Note> {
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<Note>({
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<Note>,
{ savedObjectIds: string[] },
{}
>('notes/fetchNotesBySavedObjectIds', async (args) => {
const { savedObjectIds } = args;
const res = await fetchNotesBySaveObjectIdsApi(savedObjectIds);
return normalizeEntities(res.notes);
});
export const fetchNotes = createAsyncThunk<
NormalizedEntities<Note> & { 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,

View file

@ -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(
<TestProviders>
<SaveTimelineButton timelineId="timeline-1" buttonText={'TEST'} />
</TestProviders>
);
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 },

View file

@ -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<SaveTimelineButtonProps>(({ timelineId }) => {
const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState<boolean>(false);
const toggleSaveTimeline = useCallback(() => setShowEditTimelineOverlay((prev) => !prev), []);
export const SaveTimelineButton = React.memo<SaveTimelineButtonProps>(
({
timelineId,
buttonText = i18n.SAVE,
'data-test-subj': dataTestSubj = 'timeline-modal-save-timeline',
}) => {
const [showEditTimelineOverlay, setShowEditTimelineOverlay] = useState<boolean>(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 (
<>
<EuiToolTip
content={unauthorizedMessage}
position="bottom"
data-test-subj="timeline-modal-save-timeline-tooltip"
>
<EuiButton
id={TIMELINE_TOUR_CONFIG_ANCHORS.SAVE_TIMELINE}
fill
size="s"
iconType="save"
isLoading={isSaving}
disabled={!canSaveTimeline}
data-test-subj="timeline-modal-save-timeline"
onClick={toggleSaveTimeline}
return (
<>
<EuiToolTip
content={unauthorizedMessage}
position="bottom"
data-test-subj="timeline-modal-save-timeline-tooltip"
>
{i18n.SAVE}
</EuiButton>
</EuiToolTip>
{showEditTimelineOverlay && canSaveTimeline ? (
<SaveTimelineModal
initialFocusOn={isUnsaved ? 'title' : 'save'}
timelineId={timelineId}
showWarning={false}
closeSaveTimeline={toggleSaveTimeline}
/>
) : null}
</>
);
});
<EuiButton
id={TIMELINE_TOUR_CONFIG_ANCHORS.SAVE_TIMELINE}
fill
size="s"
iconType="save"
isLoading={isSaving}
disabled={!canSaveTimeline}
data-test-subj={dataTestSubj}
onClick={toggleSaveTimeline}
>
{buttonText}
</EuiButton>
</EuiToolTip>
{showEditTimelineOverlay && canSaveTimeline ? (
<SaveTimelineModal
initialFocusOn={isUnsaved ? 'title' : 'save'}
timelineId={timelineId}
showWarning={false}
closeSaveTimeline={toggleSaveTimeline}
/>
) : null}
</>
);
}
);
SaveTimelineButton.displayName = 'SaveTimelineButton';

View file

@ -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<UsernameWithAvatar> = ({ username }) => (
<StyledEuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiAvatar data-test-subj="avatar" name={username} size="l" />
</EuiFlexItem>
<EuiFlexItem>
<Username>{username}</Username>
</EuiFlexItem>
</StyledEuiFlexGroup>
);
const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent);
interface ParticipantsProps {
users: TimelineResultNote[];
}
export const ParticipantsComponent: React.FC<ParticipantsProps> = ({ users }) => {
const List = useMemo(
() =>
users.map((user) => (
<Fragment key={user.updatedBy === null ? undefined : user.updatedBy}>
<UsernameWithAvatar
key={user.updatedBy === null ? undefined : user.updatedBy}
username={String(user.updatedBy)}
/>
<EuiSpacer size="s" />
</Fragment>
)),
[users]
);
if (!users.length) {
return null;
}
return (
<>
<EuiTitle size="xs">
<h4>{PARTICIPANTS}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
{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<NotesTabContentProps> = 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<string[]>(
(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 && (
<>
<EuiTitle size="xs">
<h4>{CREATED_BY}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<UsernameWithAvatar username={createdBy} />
<EuiSpacer size="xxl" />
</>
)}
<Participants users={participants} />
</>
),
[createdBy, participants]
);
return (
<FullWidthFlexGroup gutterSize="none" data-test-subj={'old-notes-screen'}>
<EuiFlexItem component={ScrollableDiv} grow={2} id="scrollableNotes">
<NotePreviews notes={notes} timelineId={timelineId} showTimelineDescription />
<EuiSpacer size="s" />
{!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && (
<AddNote
associateNote={associateNote}
newNote={newNote}
updateNewNote={setNewNote}
autoFocusDisabled={!!scrollToTop}
/>
)}
</EuiFlexItem>
<VerticalRule />
<EuiFlexItem component={ScrollableDiv} grow={1}>
{SidebarContent}
</EuiFlexItem>
</FullWidthFlexGroup>
);
});
OldNotes.displayName = 'OldNotes';

View file

@ -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(<Participants notes={notes} timelineCreatedBy={username} />);
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(<Participants notes={notes} timelineCreatedBy={undefined} />);
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(<Participants notes={[]} timelineCreatedBy={username} />);
expect(queryByTestId(`${NOTE_AVATAR_WITH_NAME_TEST_ID}-0`)).not.toBeInTheDocument();
});
});

View file

@ -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<UsernameWithAvatar> = React.memo(
({ username, 'data-test-subj': dataTestSubj }) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup
gutterSize="s"
responsive={false}
alignItems="center"
css={css`
flex-grow: 0;
`}
data-test-subj={dataTestSubj}
>
<EuiFlexItem grow={false}>
<EuiAvatar data-test-subj="avatar" name={username} size="l" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText
css={css`
font-weight: ${euiTheme.font.weight.bold};
`}
>
{username}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
);
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<ParticipantsProps> = 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 && (
<>
<EuiTitle size="xs" data-test-subj={TIMELINE_PARTICIPANT_TITLE_TEST_ID}>
<h4>{CREATED_BY}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<UsernameWithAvatar
username={timelineCreatedBy}
data-test-subj={TIMELINE_AVATAR_WITH_NAME_TEST_ID}
/>
<EuiSpacer size="xxl" />
</>
)}
<>
<EuiTitle size="xs" data-test-subj={NOTES_PARTICIPANTS_TITLE_TEST_ID}>
<h4>{PARTICIPANTS}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
{participants.map((participant, index) => (
<Fragment key={participant.updatedBy === null ? undefined : participant.updatedBy}>
<UsernameWithAvatar
key={participant.updatedBy === null ? undefined : participant.updatedBy}
username={String(participant.updatedBy)}
data-test-subj={`${NOTE_AVATAR_WITH_NAME_TEST_ID}-${index}`}
/>
<EuiSpacer size="s" />
</Fragment>
))}
</>
</>
);
}
);
Participants.displayName = 'Participants';

View file

@ -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(
<TestProviders store={mockStore}>
<SaveTimelineCallout />
</TestProviders>
);
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();
});
});

View file

@ -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 (
<EuiCallOut
title={SAVE_TIMELINE_CALLOUT_TITLE}
color="warning"
iconType="iInCircle"
data-test-subj={SAVE_TIMELINE_CALLOUT_TEST_ID}
css={css`
margin-left: 50px;
`}
>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem>
<EuiText size="s">{SAVE_TIMELINE_CALLOUT_CONTENT}</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<SaveTimelineButton
timelineId={TimelineId.active}
buttonText={SAVE_TIMELINE_BUTTON}
data-test-subj={SAVE_TIMELINE_BUTTON_TEST_ID}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
);
});
SaveTimelineCallout.displayName = 'SaveTimelineCallout';

View file

@ -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;

View file

@ -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', () => {

View file

@ -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<BasicTimelineTab> = ({
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<BasicTimelineTab> = ({
const isEnterprisePlus = useLicense().isEnterprise();
// old notes system (through timeline)
const allTimelineNoteIds = useMemo(() => {
const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>(
(acc, v) => [...acc, ...v],
@ -281,13 +289,43 @@ const TabsContentComponent: React.FC<BasicTimelineTab> = ({
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 }));

View file

@ -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(
<TestProviders>
<NotesTabContentComponent timelineId={TimelineId.test} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.test} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.active} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.test} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.test} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.active} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.test} />
</TestProviders>
);
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(
<TestProviders store={mockStore}>
<NotesTabContentComponent timelineId={TimelineId.active} />
</TestProviders>
);
expect(getByTestId(TIMELINE_DESCRIPTION_COMMENT_TEST_ID)).toBeInTheDocument();
expect(getByText('description')).toBeInTheDocument();
});
});

View file

@ -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 {
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';
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 {
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';
import type { State } from '../../../../../common/store';
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;
}
const UsernameWithAvatarComponent: React.FC<UsernameWithAvatar> = ({ username }) => (
<StyledEuiFlexGroup gutterSize="s" responsive={false} alignItems="center">
<EuiFlexItem grow={false}>
<EuiAvatar data-test-subj="avatar" name={username} size="l" />
</EuiFlexItem>
<EuiFlexItem>
<Username>{username}</Username>
</EuiFlexItem>
</StyledEuiFlexGroup>
);
const UsernameWithAvatar = React.memo(UsernameWithAvatarComponent);
interface ParticipantsProps {
users: TimelineResultNote[];
}
const ParticipantsComponent: React.FC<ParticipantsProps> = ({ users }) => {
const List = useMemo(
() =>
users.map((user) => (
<Fragment key={user.updatedBy === null ? undefined : user.updatedBy}>
<UsernameWithAvatar
key={user.updatedBy === null ? undefined : user.updatedBy}
username={String(user.updatedBy)}
/>
<EuiSpacer size="s" />
</Fragment>
)),
[users]
);
if (!users.length) {
return null;
export const FETCH_NOTES_ERROR = i18n.translate(
'xpack.securitySolution.notes.fetchNotesErrorLabel',
{
defaultMessage: 'Error fetching notes',
}
return (
<>
<EuiTitle size="xs">
<h4>{PARTICIPANTS}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
{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<NotesTabContentProps> = ({ 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<NotesTabContentProps> = 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 [newNote, setNewNote] = useState('');
const isImmutable = timelineStatus === TimelineStatusEnum.immutable;
const appNotes: TimelineResultNote[] = useDeepEqualSelector(getNotesAsCommentsList);
const allTimelineNoteIds = useMemo(() => {
const eventNoteIds = Object.values(eventIdToNoteIds).reduce<string[]>(
(acc, v) => [...acc, ...v],
[]
);
return [...noteIds, ...eventNoteIds];
}, [noteIds, eventIdToNoteIds]);
const notes = useMemo(
() => appNotes.filter((appNote) => allTimelineNoteIds.includes(appNote?.noteId ?? '-1')),
[appNotes, allTimelineNoteIds]
const timeline = useSelector((state: State) => selectTimelineById(state, timelineId));
const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]);
const isTimelineSaved: boolean = useMemo(
() => timelineSavedObjectId.length > 0,
[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]
const fetchNotes = useCallback(
() => dispatch(fetchNotesBySavedObjectIds({ savedObjectIds: [timelineSavedObjectId] })),
[dispatch, timelineSavedObjectId]
);
const SidebarContent = useMemo(
() => (
useEffect(() => {
if (isTimelineSaved) {
fetchNotes();
}
}, [fetchNotes, isTimelineSaved]);
const notes: Note[] = useSelector((state: State) =>
selectSortedNotesBySavedObjectId(state, {
savedObjectId: timelineSavedObjectId,
sort: { field: 'created', direction: 'asc' },
})
);
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 && (
<>
<EuiTitle size="xs">
<h4>{CREATED_BY}</h4>
</EuiTitle>
<EuiHorizontalRule margin="s" />
<UsernameWithAvatar username={createdBy} />
<EuiSpacer size="xxl" />
</>
)}
<Participants users={participants} />
<EuiComment
key={'note-preview-description'}
username={defaultToEmptyTag(timeline.updatedBy)}
timestamp={
<>
{timeline.updated ? (
<FormattedRelative data-test-subj="updated" value={new Date(timeline.updated)} />
) : (
getEmptyValue()
)}
</>
}
event={ADDED_A_DESCRIPTION}
timelineAvatar={<EuiAvatar size="l" name={timeline.updatedBy || '?'} />}
data-test-subj={TIMELINE_DESCRIPTION_COMMENT_TEST_ID}
>
<EuiText size="s">{timeline.description}</EuiText>
</EuiComment>
<EuiSpacer />
</>
),
[createdBy, participants]
);
);
}, [timeline.description, timeline.updated, timeline.updatedBy]);
return (
<FullWidthFlexGroup gutterSize="none">
<EuiFlexItem component={ScrollableDiv} grow={2} id="scrollableNotes">
<StyledPanel paddingSize="none">
<EuiPanel>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiTitle>
<h3>{NOTES}</h3>
</EuiTitle>
<NotePreviews notes={notes} timelineId={timelineId} showTimelineDescription />
<EuiSpacer size="s" />
{!isImmutable && kibanaSecuritySolutionsPrivileges.crud === true && (
<AddNote
associateNote={associateNote}
newNote={newNote}
updateNewNote={setNewNote}
autoFocusDisabled={!!scrollToTop}
/>
</EuiFlexItem>
<EuiFlexItem>
{securitySolutionNotesEnabled ? (
<EuiFlexGroup data-test-subj={'new-notes-screen'}>
<EuiFlexItem>
{timelineDescription}
{fetchStatus === ReqStatus.Loading && (
<EuiLoadingElastic data-test-subj={NOTES_LOADING_TEST_ID} size="xxl" />
)}
{isTimelineSaved && fetchStatus === ReqStatus.Succeeded && notes.length === 0 ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<p>{NO_NOTES}</p>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<NotesList notes={notes} options={{ hideTimelineIcon: true }} />
)}
{canCreateNotes && (
<>
<EuiSpacer />
<AddNote timelineId={timeline.savedObjectId} disableButton={!isTimelineSaved}>
{!isTimelineSaved && <SaveTimelineCallout />}
</AddNote>
</>
)}
</EuiFlexItem>
<EuiFlexItem
css={css`
max-width: 350px;
`}
>
<Participants notes={notes} timelineCreatedBy={timeline.createdBy} />
</EuiFlexItem>
</EuiFlexGroup>
) : (
<OldNotes timelineId={timelineId} />
)}
</StyledPanel>
</EuiFlexItem>
<VerticalRule />
<EuiFlexItem component={ScrollableDiv} grow={1}>
{SidebarContent}
</EuiFlexItem>
</FullWidthFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
});
NotesTabContentComponent.displayName = 'NotesTabContentComponent';
const NotesTabContent = React.memo(NotesTabContentComponent);
// eslint-disable-next-line import/no-default-export
export { NotesTabContent as default };
export { NotesTabContentComponent as default };

View file

@ -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

View file

@ -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<typeof serverMock.create>;
@ -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,
});
});
});

View file

@ -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;