Skip to content

Commit

Permalink
[8.x] [Security Solution][Notes] - fix pinning behavior in document n…
Browse files Browse the repository at this point in the history
…otes (#194473) (#194890)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][Notes] - fix pinning behavior in document notes
(#194473)](#194473)

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

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

<!--BACKPORT
[{"author":{"name":"christineweng","email":"[email protected]"},"sourceCommit":{"committedDate":"2024-10-04T00:42:26Z","message":"[Security
Solution][Notes] - fix pinning behavior in document notes
(#194473)\n\n## Summary\r\n\r\nFixed some pinning behaviors in new notes
system, namely:\r\n\r\n- Pinning is greyed out only when an event has a
note attached to the\r\n**current** timeline, else pinning should work
as usual
(related:\r\nhttps://github.com//issues/193738)\r\n-
Adding a note and attaching to current timeline automatically pins
the\r\nevent\r\n- Pinned tab and pinning capability are updated when a
note attached to\r\ncurrent timeline is deleted\r\n\r\nFeature flag:
`securitySolutionNotesEnabled`\r\n\r\n\r\nhttps://github.com/user-attachments/assets/0163d4a4-354c-4928-a337-40a93f6c7b2a\r\n\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"883dfa8ae887c1ee57049a45d8ed8ceb7c2a34d6","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","Team:Threat
Hunting","Team:Threat
Hunting:Investigations","backport:prev-minor","v8.16.0"],"title":"[Security
Solution][Notes] - fix pinning behavior in document
notes","number":194473,"url":"https://github.com/elastic/kibana/pull/194473","mergeCommit":{"message":"[Security
Solution][Notes] - fix pinning behavior in document notes
(#194473)\n\n## Summary\r\n\r\nFixed some pinning behaviors in new notes
system, namely:\r\n\r\n- Pinning is greyed out only when an event has a
note attached to the\r\n**current** timeline, else pinning should work
as usual
(related:\r\nhttps://github.com//issues/193738)\r\n-
Adding a note and attaching to current timeline automatically pins
the\r\nevent\r\n- Pinned tab and pinning capability are updated when a
note attached to\r\ncurrent timeline is deleted\r\n\r\nFeature flag:
`securitySolutionNotesEnabled`\r\n\r\n\r\nhttps://github.com/user-attachments/assets/0163d4a4-354c-4928-a337-40a93f6c7b2a\r\n\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"883dfa8ae887c1ee57049a45d8ed8ceb7c2a34d6"}},"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/194473","number":194473,"mergeCommit":{"message":"[Security
Solution][Notes] - fix pinning behavior in document notes
(#194473)\n\n## Summary\r\n\r\nFixed some pinning behaviors in new notes
system, namely:\r\n\r\n- Pinning is greyed out only when an event has a
note attached to the\r\n**current** timeline, else pinning should work
as usual
(related:\r\nhttps://github.com//issues/193738)\r\n-
Adding a note and attaching to current timeline automatically pins
the\r\nevent\r\n- Pinned tab and pinning capability are updated when a
note attached to\r\ncurrent timeline is deleted\r\n\r\nFeature flag:
`securitySolutionNotesEnabled`\r\n\r\n\r\nhttps://github.com/user-attachments/assets/0163d4a4-354c-4928-a337-40a93f6c7b2a\r\n\r\n\r\n###
Checklist\r\n\r\n- [x] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common
scenarios","sha":"883dfa8ae887c1ee57049a45d8ed8ceb7c2a34d6"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: christineweng <[email protected]>
  • Loading branch information
kibanamachine and christineweng authored Oct 4, 2024
1 parent 34940a2 commit 8bad3f1
Show file tree
Hide file tree
Showing 10 changed files with 155 additions and 35 deletions.
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
12 changes: 12 additions & 0 deletions x-pack/plugins/security_solution/public/notes/store/notes.slice.ts
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(
[
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

0 comments on commit 8bad3f1

Please sign in to comment.