mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# 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:
parent
29c4ccefd5
commit
17dfc64aa4
44 changed files with 2891 additions and 1070 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 }));
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue