Skip to content

Commit

Permalink
[Security Solution][Notes] - add ability to link a note to a timeline…
Browse files Browse the repository at this point in the history
…, and show an icon to note to launch the timeline (elastic#186906)
  • Loading branch information
PhilippeOberti authored Jun 26, 2024
1 parent 8ad9812 commit 433c6a0
Show file tree
Hide file tree
Showing 6 changed files with 313 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@
* 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 } from './test_ids';
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 { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open';
import { TimelineId } from '../../../../../common/types';

jest.mock('../../shared/hooks/use_is_timeline_flyout_open');

const mockAddError = jest.fn();
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
Expand Down Expand Up @@ -41,6 +50,7 @@ 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', () => {
Expand Down Expand Up @@ -98,4 +108,63 @@ describe('AddNote', () => {
title: CREATE_NOTE_ERROR,
});
});

it('should disable attach to timeline checkbox if flyout is not open from timeline', () => {
(useIsTimelineFlyoutOpen as jest.Mock).mockReturnValue(false);

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', () => {
(useIsTimelineFlyoutOpen as jest.Mock).mockReturnValue(true);

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', () => {
(useIsTimelineFlyoutOpen as jest.Mock).mockReturnValue(true);

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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,26 @@
import React, { memo, useCallback, useEffect, useState } from 'react';
import {
EuiButton,
EuiCheckbox,
EuiComment,
EuiCommentList,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { ADD_NOTE_BUTTON_TEST_ID, ADD_NOTE_MARKDOWN_TEST_ID } from './test_ids';
import { TimelineId } from '../../../../../common/types';
import { timelineSelectors } from '../../../../timelines/store';
import { useIsTimelineFlyoutOpen } from '../../shared/hooks/use_is_timeline_flyout_open';
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 {
Expand All @@ -27,6 +38,8 @@ import {
} from '../../../../notes/store/notes.slice';
import { MarkdownEditor } from '../../../../common/components/markdown_editor';

const timelineCheckBoxId = 'xpack.securitySolution.notes.attachToTimelineCheckboxId';

export const MARKDOWN_ARIA_LABEL = i18n.translate(
'xpack.securitySolution.notes.markdownAriaLabel',
{
Expand All @@ -42,6 +55,18 @@ export const CREATE_NOTE_ERROR = i18n.translate(
defaultMessage: 'Error create note',
}
);
export const ATTACH_TO_TIMELINE_CHECKBOX = i18n.translate(
'xpack.securitySolution.notes.attachToTimelineCheckboxLabel',
{
defaultMessage: 'Attach to active timeline',
}
);
export const ATTACH_TO_TIMELINE_INFO = i18n.translate(
'xpack.securitySolution.notes.attachToTimelineInfoLabel',
{
defaultMessage: 'The active timeline must be saved before a note can be associated with it',
}
);

export interface AddNewNoteProps {
/**
Expand All @@ -51,29 +76,43 @@ export interface AddNewNoteProps {
}

/**
* Renders a markdown editor and a add button to create new notes
* 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 dispatch = useDispatch();
const { addError: addErrorToast } = useAppToasts();
const [editorValue, setEditorValue] = useState('');

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 = useIsTimelineFlyoutOpen();
const [checked, setChecked] = useState(isTimelineFlyout && activeTimeline.savedObjectId != null);
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: '',
timelineId: (checked && activeTimeline?.savedObjectId) || '',
eventId,
note: editorValue,
},
})
);
setEditorValue('');
}, [dispatch, editorValue, eventId]);
}, [activeTimeline?.savedObjectId, checked, dispatch, editorValue, eventId]);

// show a toast if the create note call fails
useEffect(() => {
if (createStatus === ReqStatus.Failed && createError) {
addErrorToast(null, {
Expand All @@ -82,6 +121,9 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => {
}
}, [addErrorToast, createError, createStatus]);

const checkBoxDisabled =
!isTimelineFlyout || (isTimelineFlyout && activeTimeline.savedObjectId == null);

return (
<>
<EuiCommentList>
Expand All @@ -96,7 +138,31 @@ export const AddNote = memo(({ eventId }: AddNewNoteProps) => {
</EuiComment>
</EuiCommentList>
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd" responsive={false}>
<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: 4px;
`}
/>
</EuiToolTip>
</>
}
disabled={checkBoxDisabled}
checked={checked}
onChange={(e) => onCheckboxChange(e)}
/>
</>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={addNote}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ import {
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 { FETCH_NOTES_ERROR, NO_NOTES, NotesList } from './notes_list';
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';

jest.mock('../../../../timelines/components/open_timeline/helpers');

const mockAddError = jest.fn();
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
Expand Down Expand Up @@ -47,6 +51,7 @@ describe('NotesList', () => {
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();
});

Expand Down Expand Up @@ -124,26 +129,30 @@ describe('NotesList', () => {
...mockGlobalState,
notes: {
...mockGlobalState.notes,
status: {
...mockGlobalState.notes.status,
fetchNotesByDocumentId: ReqStatus.Failed,
},
error: {
...mockGlobalState.notes.error,
fetchNotesByDocumentId: { type: 'http', status: 500 },
entities: {
'1': {
eventId: 'event-id',
noteId: '1',
note: 'note-1',
timelineId: '',
created: 1663882629000,
createdBy: 'elastic',
updated: 1663882629000,
updatedBy: null,
version: 'version',
},
},
},
});

render(
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
</TestProviders>
);
const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`));

expect(mockAddError).toHaveBeenCalledWith(null, {
title: FETCH_NOTES_ERROR,
});
expect(getByText('?')).toBeInTheDocument();
});

it('should render create loading when user creates a new note', () => {
Expand Down Expand Up @@ -202,6 +211,50 @@ describe('NotesList', () => {
});

it('should render error toast if deleting a note fails', () => {
const store = createMockStore({
...mockGlobalState,
notes: {
...mockGlobalState.notes,
status: {
...mockGlobalState.notes.status,
deleteNote: ReqStatus.Failed,
},
error: {
...mockGlobalState.notes.error,
deleteNote: { type: 'http', status: 500 },
},
},
});

render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
</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,
unifiedComponentsInTimelineEnabled: false,
});
});

it('should not render timeline icon if no timeline is related to the note', () => {
const store = createMockStore({
...mockGlobalState,
notes: {
Expand All @@ -215,20 +268,19 @@ describe('NotesList', () => {
created: 1663882629000,
createdBy: 'elastic',
updated: 1663882629000,
updatedBy: null,
updatedBy: 'elastic',
version: 'version',
},
},
},
});

const { getByTestId } = render(
const { queryByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
</TestProviders>
);
const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`));

expect(getByText('?')).toBeInTheDocument();
expect(queryByTestId(`${OPEN_TIMELINE_BUTTON_TEST_ID}-0`)).not.toBeInTheDocument();
});
});
Loading

0 comments on commit 433c6a0

Please sign in to comment.