[Security Solution][Notes] - show fetched notes in the flyout left section (#186787)

This commit is contained in:
Philippe Oberti 2024-06-24 08:50:18 -05:00 committed by GitHub
parent 9280bddf66
commit bb669bbd91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 397 additions and 3 deletions

View file

@ -0,0 +1,41 @@
/*
* 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 { LeftPanelContext } from '../context';
import { TestProviders } from '../../../../common/mock';
import { NotesDetails } from './notes_details';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const panelContextValue = {
eventId: 'event id',
} as unknown as LeftPanelContext;
const renderNotesDetails = () =>
render(
<TestProviders>
<LeftPanelContext.Provider value={panelContextValue}>
<NotesDetails />
</LeftPanelContext.Provider>
</TestProviders>
);
describe('NotesDetails', () => {
it('should fetch notes for the document id', () => {
renderNotesDetails();
expect(mockDispatch).toHaveBeenCalled();
});
});

View file

@ -7,6 +7,7 @@
import React, { memo, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { NotesList } from './notes_list';
import { fetchNotesByDocumentId } from '../../../../notes/store/notes.slice';
import { useLeftPanelContext } from '../context';
@ -22,7 +23,7 @@ export const NotesDetails = memo(() => {
dispatch(fetchNotesByDocumentId({ documentId: eventId }));
}, [dispatch, eventId]);
return <></>;
return <NotesList eventId={eventId} />;
});
NotesDetails.displayName = 'NotesDetails';

View file

@ -0,0 +1,80 @@
/*
* 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 { NOTES_COMMENT_TEST_ID, NOTES_LOADING_TEST_ID } from './test_ids';
import { createMockStore, mockGlobalState, TestProviders } from '../../../../common/mock';
import { FETCH_NOTES_ERROR, NO_NOTES, NotesList } from './notes_list';
import { ReqStatus } from '../../../../notes/store/notes.slice';
const mockAddError = jest.fn();
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
useAppToasts: () => ({
addError: mockAddError,
}),
}));
const renderNotesList = () =>
render(
<TestProviders>
<NotesList eventId={'event-id'} />
</TestProviders>
);
describe('NotesList', () => {
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();
});
it('should render loading spinner if notes are being fetched', () => {
const state = { ...mockGlobalState };
state.notes.status.fetchNotesByDocumentId = ReqStatus.Loading;
const store = createMockStore(state);
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
</TestProviders>
);
expect(getByTestId(NOTES_LOADING_TEST_ID)).toBeInTheDocument();
});
it('should render no data message if no notes are present', () => {
const state = { ...mockGlobalState };
state.notes.status.fetchNotesByDocumentId = ReqStatus.Succeeded;
const store = createMockStore(state);
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 state = { ...mockGlobalState };
state.notes.status.fetchNotesByDocumentId = ReqStatus.Failed;
state.notes.error.fetchNotesByDocumentId = { type: 'http', status: 500 };
const store = createMockStore(state);
render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
</TestProviders>
);
expect(mockAddError).toHaveBeenCalledWith(null, {
title: FETCH_NOTES_ERROR,
});
});
});

View file

@ -0,0 +1,88 @@
/*
* 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, { useEffect } from 'react';
import { EuiComment, EuiCommentList, EuiLoadingElastic, EuiMarkdownFormat } from '@elastic/eui';
import { useSelector } from 'react-redux';
import { FormattedRelative } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { NOTES_COMMENT_TEST_ID, NOTES_LOADING_TEST_ID } from './test_ids';
import type { State } from '../../../../common/store';
import type { Note } from '../../../../../common/api/timeline';
import {
ReqStatus,
selectFetchNotesByDocumentIdError,
selectFetchNotesByDocumentIdStatus,
selectNotesByDocumentId,
} from '../../../../notes/store/notes.slice';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
export const ADDED_A_NOTE = i18n.translate('xpack.securitySolution.notes.addedANoteLabel', {
defaultMessage: 'added a note',
});
export const FETCH_NOTES_ERROR = i18n.translate(
'xpack.securitySolution.notes.fetchNoteErrorLabel',
{
defaultMessage: 'Error fetching notes',
}
);
export const NO_NOTES = i18n.translate('xpack.securitySolution.notes.noNotesLabel', {
defaultMessage: 'No notes have been created for this document',
});
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.
*/
export const NotesList = ({ eventId }: NotesListProps) => {
const { addError: addErrorToast } = useAppToasts();
const fetchStatus = useSelector((state: State) => selectFetchNotesByDocumentIdStatus(state));
const fetchError = useSelector((state: State) => selectFetchNotesByDocumentIdError(state));
const notes: Note[] = useSelector((state: State) => selectNotesByDocumentId(state, eventId));
useEffect(() => {
if (fetchStatus === ReqStatus.Failed && fetchError) {
addErrorToast(null, {
title: FETCH_NOTES_ERROR,
});
}
}, [addErrorToast, fetchError, fetchStatus]);
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-${index}`}
username={note.createdBy}
timestamp={<>{note.created && <FormattedRelative value={new Date(note.created)} />}</>}
event={ADDED_A_NOTE}
>
<EuiMarkdownFormat textSize="s">{note.note || ''}</EuiMarkdownFormat>
</EuiComment>
))}
</EuiCommentList>
);
};
NotesList.displayName = 'NotesList';

View file

@ -89,3 +89,8 @@ export const RESPONSE_NO_DATA_TEST_ID = `${RESPONSE_TEST_ID}NoData` as const;
export const INVESTIGATION_GUIDE_TEST_ID = `${PREFIX}InvestigationGuide` as const;
export const INVESTIGATION_GUIDE_LOADING_TEST_ID = `${INVESTIGATION_GUIDE_TEST_ID}Loading` as const;
/* Notes */
export const NOTES_LOADING_TEST_ID = `${PREFIX}NotesLoading` as const;
export const NOTES_COMMENT_TEST_ID = `${PREFIX}NotesComment` as const;

View file

@ -20,7 +20,7 @@ export const fetchNotesByDocumentId = async (documentId: string) => {
};
// TODO remove when the API is available
const generateNoteMock = (documentId: string) => ({
export const generateNoteMock = (documentId: string) => ({
noteId: uuid.v4(),
version: 'WzU1MDEsMV0=',
timelineId: '',

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/plugins/security_solution/public/notes'],
coverageDirectory:
'<rootDir>/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/notes',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/x-pack/plugins/security_solution/public/notes/**/*.{ts,tsx}'],
moduleNameMapper: require('../../server/__mocks__/module_name_map'),
};

View file

@ -0,0 +1,150 @@
/*
* 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 {
fetchNotesByDocumentId,
initialNotesState,
notesReducer,
ReqStatus,
selectAllNotes,
selectFetchNotesByDocumentIdError,
selectFetchNotesByDocumentIdStatus,
selectNoteById,
selectNoteIds,
selectNotesByDocumentId,
} from './notes.slice';
import { generateNoteMock } from '../api/api';
import { mockGlobalState } from '../../common/mock';
const initalEmptyState = initialNotesState;
const mockNote = { ...generateNoteMock('1') };
const initialNonEmptyState = {
entities: {
[mockNote.noteId]: mockNote,
},
ids: [mockNote.noteId],
status: { fetchNotesByDocumentId: ReqStatus.Idle },
error: { fetchNotesByDocumentId: null },
};
describe('notesSlice', () => {
describe('notesReducer', () => {
it('should handle an unknown action and return the initial state', () => {
expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual({
entities: {},
ids: [],
status: { fetchNotesByDocumentId: ReqStatus.Idle },
error: { fetchNotesByDocumentId: null },
});
});
it('should set correct state when fetching notes by document id', () => {
const action = { type: fetchNotesByDocumentId.pending.type };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
status: { fetchNotesByDocumentId: ReqStatus.Loading },
error: { fetchNotesByDocumentId: null },
});
});
it('should set correct state when success on fetch notes by document id on an empty state', () => {
const action = {
type: fetchNotesByDocumentId.fulfilled.type,
payload: {
entities: {
notes: {
[mockNote.noteId]: mockNote,
},
},
result: [mockNote.noteId],
},
};
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: action.payload.entities.notes,
ids: action.payload.result,
status: { fetchNotesByDocumentId: ReqStatus.Succeeded },
error: { fetchNotesByDocumentId: null },
});
});
it('should replace notes when success on fetch notes by document id on a non-empty state', () => {
const newMockNote = { ...mockNote, timelineId: 'timelineId' };
const action = {
type: fetchNotesByDocumentId.fulfilled.type,
payload: {
entities: {
notes: {
[newMockNote.noteId]: newMockNote,
},
},
result: [newMockNote.noteId],
},
};
expect(notesReducer(initialNonEmptyState, action)).toEqual({
entities: action.payload.entities.notes,
ids: action.payload.result,
status: { fetchNotesByDocumentId: ReqStatus.Succeeded },
error: { fetchNotesByDocumentId: null },
});
});
it('should set correct state when error on fetch notes by document id', () => {
const action = { type: fetchNotesByDocumentId.rejected.type, error: 'error' };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
status: { fetchNotesByDocumentId: ReqStatus.Failed },
error: { fetchNotesByDocumentId: 'error' },
});
});
});
describe('selectors', () => {
it('should return all notes', () => {
const state = mockGlobalState;
state.notes.entities = initialNonEmptyState.entities;
state.notes.ids = initialNonEmptyState.ids;
expect(selectAllNotes(state)).toEqual([mockNote]);
});
it('should return note by id', () => {
const state = mockGlobalState;
state.notes.entities = initialNonEmptyState.entities;
state.notes.ids = initialNonEmptyState.ids;
expect(selectNoteById(state, mockNote.noteId)).toEqual(mockNote);
});
it('should return note ids', () => {
const state = mockGlobalState;
state.notes.entities = initialNonEmptyState.entities;
state.notes.ids = initialNonEmptyState.ids;
expect(selectNoteIds(state)).toEqual([mockNote.noteId]);
});
it('should return fetch notes by document id status', () => {
expect(selectFetchNotesByDocumentIdStatus(mockGlobalState)).toEqual(ReqStatus.Idle);
});
it('should return fetch notes by document id error', () => {
expect(selectFetchNotesByDocumentIdError(mockGlobalState)).toEqual(null);
});
it('should return all notes for an existing document id', () => {
expect(selectNotesByDocumentId(mockGlobalState, '1')).toEqual([mockNote]);
});
it('should return no notes if document id does not exist', () => {
expect(selectNotesByDocumentId(mockGlobalState, '2')).toHaveLength(0);
});
});
});

View file

@ -7,6 +7,7 @@
import type { EntityState, SerializedError } from '@reduxjs/toolkit';
import { createAsyncThunk, createEntityAdapter, createSlice } from '@reduxjs/toolkit';
import { createSelector } from 'reselect';
import type { State } from '../../common/store';
import { fetchNotesByDocumentId as fetchNotesByDocumentIdApi } from '../api/api';
import type { NormalizedEntities } from './normalize';
@ -63,7 +64,7 @@ const notesSlice = createSlice({
reducers: {},
extraReducers(builder) {
builder
.addCase(fetchNotesByDocumentId.pending, (state, action) => {
.addCase(fetchNotesByDocumentId.pending, (state) => {
state.status.fetchNotesByDocumentId = ReqStatus.Loading;
})
.addCase(fetchNotesByDocumentId.fulfilled, (state, action) => {
@ -84,3 +85,14 @@ export const {
selectById: selectNoteById,
selectIds: selectNoteIds,
} = notesAdapter.getSelectors((state: State) => state.notes);
export const selectFetchNotesByDocumentIdStatus = (state: State) =>
state.notes.status.fetchNotesByDocumentId;
export const selectFetchNotesByDocumentIdError = (state: State) =>
state.notes.error.fetchNotesByDocumentId;
export const selectNotesByDocumentId = createSelector(
[selectAllNotes, (state, documentId) => documentId],
(notes, documentId) => notes.filter((note) => note.eventId === documentId)
);