mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Security Solution][Notes] - show fetched notes in the flyout left section (#186787)
This commit is contained in:
parent
9280bddf66
commit
bb669bbd91
9 changed files with 397 additions and 3 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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: '',
|
||||
|
|
17
x-pack/plugins/security_solution/public/notes/jest.config.js
Normal file
17
x-pack/plugins/security_solution/public/notes/jest.config.js
Normal 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'),
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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)
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue