Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Notes] - fix pinning behavior in document notes #194473

Merged
merged 4 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';

import { TimelineTabs, TableId } from '@kbn/securitysolution-data-table';
import { selectNotesByDocumentId } from '../../../notes/store/notes.slice';
import {
selectNotesByDocumentId,
selectDocumentNotesBySavedObjectId,
} from '../../../notes/store/notes.slice';
import type { State } from '../../store';
import { selectTimelineById } from '../../../timelines/store/selectors';
import {
Expand Down Expand Up @@ -70,7 +73,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
}) => {
const dispatch = useDispatch();

const { timelineType } = useShallowEqualSelector((state) =>
const { timelineType, savedObjectId } = useShallowEqualSelector((state) =>
isTimelineScope(timelineId) ? selectTimelineById(state, timelineId) : timelineDefaults
);

Expand Down Expand Up @@ -222,24 +225,34 @@ const ActionsComponent: React.FC<ActionProps> = ({

/* only applicable for new event based notes */
const documentBasedNotes = useSelector((state: State) => selectNotesByDocumentId(state, eventId));

/* only applicable notes before event based notes */
const timelineNoteIds = useMemo(
() => eventIdToNoteIds?.[eventId] ?? emptyNotes,
[eventIdToNoteIds, eventId]
const documentBasedNotesInTimeline = useSelector((state: State) =>
selectDocumentNotesBySavedObjectId(state, {
documentId: eventId,
savedObjectId: savedObjectId ?? '',
})
);

/* note ids associated with the document AND attached to the current timeline, used for pinning */
const timelineNoteIds = useMemo(() => {
if (securitySolutionNotesEnabled) {
// if timeline is unsaved, there is no notes associated to timeline yet
return savedObjectId ? documentBasedNotesInTimeline.map((note) => note.noteId) : [];
}
return eventIdToNoteIds?.[eventId] ?? emptyNotes;
}, [
eventIdToNoteIds,
eventId,
documentBasedNotesInTimeline,
savedObjectId,
securitySolutionNotesEnabled,
]);

/* note count of the document */
const notesCount = useMemo(
() => (securitySolutionNotesEnabled ? documentBasedNotes.length : timelineNoteIds.length),
[documentBasedNotes, timelineNoteIds, securitySolutionNotesEnabled]
);

const noteIds = useMemo(() => {
return securitySolutionNotesEnabled
? documentBasedNotes.map((note) => note.noteId)
: timelineNoteIds;
}, [documentBasedNotes, timelineNoteIds, securitySolutionNotesEnabled]);

return (
<ActionsContainer data-test-subj="actions-container">
<>
Expand Down Expand Up @@ -291,7 +304,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
isAlert={isAlert(eventType)}
key="pin-event"
onPinClicked={handlePinClicked}
noteIds={noteIds}
noteIds={timelineNoteIds}
eventIsPinned={isEventPinned}
timelineType={timelineType}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ const mockGlobalStateWithSavedTimeline = {
[TimelineId.active]: {
...mockGlobalState.timeline.timelineById[TimelineId.test],
savedObjectId: 'savedObjectId',
pinnedEventIds: {},
},
},
},
};

const mockStore = createMockStore(mockGlobalStateWithSavedTimeline);
const renderNotesDetails = () =>
render(
<TestProviders>
<TestProviders store={mockStore}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
Expand All @@ -81,16 +83,7 @@ describe('NotesDetails', () => {
});

it('should fetch notes for the document id', () => {
const mockStore = createMockStore(mockGlobalStateWithSavedTimeline);

render(
<TestProviders store={mockStore}>
<DocumentDetailsContext.Provider value={panelContextValue}>
<NotesDetails />
</DocumentDetailsContext.Provider>
</TestProviders>
);

renderNotesDetails();
expect(mockDispatch).toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 { pinEvent } from '../../../../timelines/store/actions';
import type { State } from '../../../../common/store';
import type { Note } from '../../../../../common/api/timeline';
import {
Expand Down Expand Up @@ -64,6 +65,19 @@ export const NotesDetails = memo(() => {
);
const timelineSavedObjectId = useMemo(() => timeline?.savedObjectId ?? '', [timeline]);

// Automatically pin an associated event if it's attached to a timeline and it's not pinned yet
const onNoteAddInTimeline = useCallback(() => {
const isEventPinned = eventId ? timeline?.pinnedEventIds[eventId] === true : false;
if (!isEventPinned && eventId && timelineSavedObjectId) {
dispatch(
pinEvent({
id: TimelineId.active,
eventId,
})
);
}
}, [dispatch, eventId, timelineSavedObjectId, timeline.pinnedEventIds]);

const notes: Note[] = useSelector((state: State) =>
selectSortedNotesByDocumentId(state, {
documentId: eventId,
Expand Down Expand Up @@ -111,6 +125,7 @@ export const NotesDetails = memo(() => {
<AddNote
eventId={eventId}
timelineId={isTimelineFlyout && attachToTimeline ? timelineSavedObjectId : ''}
onNoteAdd={isTimelineFlyout && attachToTimeline ? onNoteAddInTimeline : undefined}
>
{isTimelineFlyout && (
<AttachToActiveTimeline
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,17 @@ describe('AddNote', () => {
title: CREATE_NOTE_ERROR,
});
});

it('should call onNodeAdd callback when it is available', async () => {
const onNodeAdd = jest.fn();

const { getByTestId } = render(
<TestProviders>
<AddNote eventId={'event-id'} onNoteAdd={onNodeAdd} />
</TestProviders>
);
await userEvent.type(getByTestId('euiMarkdownEditorTextArea'), 'new note');
getByTestId(ADD_NOTE_BUTTON_TEST_ID).click();
expect(onNodeAdd).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,18 @@ export interface AddNewNoteProps {
* Children to render between the markdown and the add note button
*/
children?: React.ReactNode;
/*
* Callback to execute when a new note is added
*/
onNoteAdd?: () => void;
}

/**
* 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) => {
({ eventId, timelineId, disableButton = false, children, onNoteAdd }: AddNewNoteProps) => {
const { telemetry } = useKibana().services;
const dispatch = useDispatch();
const { addError: addErrorToast } = useAppToasts();
Expand All @@ -88,11 +92,14 @@ export const AddNote = memo(
},
})
);
if (onNoteAdd) {
onNoteAdd();
}
telemetry.reportAddNoteFromExpandableFlyoutClicked({
isRelatedToATimeline: timelineId != null,
});
setEditorValue('');
}, [dispatch, editorValue, eventId, telemetry, timelineId]);
}, [dispatch, editorValue, eventId, telemetry, timelineId, onNoteAdd]);

// show a toast if the create note call fails
useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
selectNoteById,
selectNoteIds,
selectNotesByDocumentId,
selectDocumentNotesBySavedObjectId,
selectNotesPagination,
selectNotesTablePendingDeleteIds,
selectNotesTableSearch,
Expand Down Expand Up @@ -608,6 +609,30 @@ describe('notesSlice', () => {
expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0);
});

it('should return no notes if no notes is found with specified document id and saved object id', () => {
expect(
selectDocumentNotesBySavedObjectId(mockGlobalState, {
documentId: '1',
savedObjectId: 'wrong-savedObjectId',
})
).toHaveLength(0);
expect(
selectDocumentNotesBySavedObjectId(mockGlobalState, {
documentId: 'wrong-document-id',
savedObjectId: 'some-timeline-id',
})
).toHaveLength(0);
});

it('should return all notes for an existing document id and existing saved object id', () => {
expect(
selectDocumentNotesBySavedObjectId(mockGlobalState, {
documentId: '1',
savedObjectId: 'timeline-1',
})
).toHaveLength(1);
});

it('should return all notes sorted for an existing document id', () => {
const oldestNote = {
eventId: '1', // should be a valid id based on mockTimelineData
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,18 @@ export const selectNotesBySavedObjectId = createSelector(
savedObjectId.length > 0 ? notes.filter((note) => note.timelineId === savedObjectId) : []
);

export const selectDocumentNotesBySavedObjectId = createSelector(
christineweng marked this conversation as resolved.
Show resolved Hide resolved
[
selectAllNotes,
(
state: State,
{ documentId, savedObjectId }: { documentId: string; savedObjectId: string }
) => ({ documentId, savedObjectId }),
],
(notes, { documentId, savedObjectId }) =>
notes.filter((note) => note.eventId === documentId && note.timelineId === savedObjectId)
);

export const selectSortedNotesByDocumentId = createSelector(
[
selectAllNotes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('helpers', () => {
eventHasNotes: true,
timelineType: TimelineTypeEnum.default,
})
).toEqual('This event cannot be unpinned because it has notes');
).toEqual('This event cannot be unpinned because it has notes in Timeline');
});

test('it indicates the alert may NOT be unpinned when `isPinned` is `true` and the alert has notes', () => {
Expand All @@ -39,7 +39,7 @@ describe('helpers', () => {
eventHasNotes: true,
timelineType: TimelineTypeEnum.default,
})
).toEqual('This alert cannot be unpinned because it has notes');
).toEqual('This alert cannot be unpinned because it has notes in Timeline');
});

test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => {
Expand Down Expand Up @@ -72,7 +72,7 @@ describe('helpers', () => {
eventHasNotes: true,
timelineType: TimelineTypeEnum.default,
})
).toEqual('This event cannot be unpinned because it has notes');
).toEqual('This event cannot be unpinned because it has notes in Timeline');
});

test('it indicates the alert is pinned when `isPinned` is `false` and the alert has notes', () => {
Expand All @@ -83,7 +83,7 @@ describe('helpers', () => {
eventHasNotes: true,
timelineType: TimelineTypeEnum.default,
})
).toEqual('This alert cannot be unpinned because it has notes');
).toEqual('This alert cannot be unpinned because it has notes in Timeline');
});

test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const PINNED_WITH_NOTES = (isAlert: boolean) =>
i18n.translate('xpack.securitySolution.timeline.body.pinning.pinnnedWithNotesTooltip', {
values: { isAlert },
defaultMessage:
'This {isAlert, select, true{alert} other{event}} cannot be unpinned because it has notes',
'This {isAlert, select, true{alert} other{event}} cannot be unpinned because it has notes in Timeline',
});

export const SORTED_ASCENDING = i18n.translate(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1026,9 +1026,31 @@ describe('query tab with unified timeline', () => {
);
});
it(
'should have the pin button with correct tooltip',
'should disable pinning when event has notes attached in timeline',
async () => {
renderTestComponents();
const mockStateWithNoteInTimeline = {
...mockGlobalState,
timeline: {
...mockGlobalState.timeline,
timelineById: {
[TimelineId.test]: {
...mockGlobalState.timeline.timelineById[TimelineId.test],
savedObjectId: 'timeline-1', // match timelineId in mocked notes data
pinnedEventIds: { '1': true },
},
},
},
};

render(
<TestProviders
store={createMockStore({
...structuredClone(mockStateWithNoteInTimeline),
})}
>
<TestComponent />
</TestProviders>
);

expect(await screen.findByTestId('discoverDocTable')).toBeVisible();

Expand All @@ -1041,7 +1063,7 @@ describe('query tab with unified timeline', () => {
await waitFor(() => {
expect(screen.getByTestId('timeline-action-pin-tool-tip')).toBeVisible();
expect(screen.getByTestId('timeline-action-pin-tool-tip')).toHaveTextContent(
'This event cannot be unpinned because it has notes'
'This event cannot be unpinned because it has notes in Timeline'
);
/*
* Above event is alert and not an event but `getEventType` in
Expand All @@ -1054,6 +1076,26 @@ describe('query tab with unified timeline', () => {
},
SPECIAL_TEST_TIMEOUT
);

it(
'should allow pinning when event has notes but notes are not attached in current timeline',
async () => {
renderTestComponents();
expect(await screen.findByTestId('discoverDocTable')).toBeVisible();

expect(screen.getAllByTestId('pin')).toHaveLength(1);
expect(screen.getByTestId('pin')).not.toBeDisabled();

fireEvent.mouseOver(screen.getByTestId('pin'));
await waitFor(() => {
expect(screen.getByTestId('timeline-action-pin-tool-tip')).toBeVisible();
expect(screen.getByTestId('timeline-action-pin-tool-tip')).toHaveTextContent(
'Pin event'
);
});
},
SPECIAL_TEST_TIMEOUT
);
});

describe('securitySolutionNotesEnabled = false', () => {
Expand Down