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][Timeline] Notes management table #187214

Merged
merged 33 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
340da96
WIP api hack and slash attempt
kqualters-elastic May 23, 2024
78de8a8
WIP allow deleting more than 1 note at a time
kqualters-elastic Jun 6, 2024
298cb2b
Merge branch 'main' into event-notes
kqualters-elastic Jun 10, 2024
c187da9
Limit notes to 1000 via fake reference
kqualters-elastic Jun 10, 2024
e43e53f
Fix validation and GET for 1 id
kqualters-elastic Jun 11, 2024
9feb1f8
Merge branch 'main' into event-notes
kqualters-elastic Jun 25, 2024
964a639
Add tests, fix types, remove demo frontend changes
kqualters-elastic Jun 26, 2024
4eef238
Merge remote-tracking branch 'upstream/main' into event-notes
kqualters-elastic Jun 26, 2024
c2ddbba
Remove dangling frontend changes
kqualters-elastic Jun 26, 2024
4a19aa8
PR feedback
kqualters-elastic Jun 26, 2024
628d7e1
Fix failing test
kqualters-elastic Jun 26, 2024
564d02d
Revert type change
kqualters-elastic Jun 26, 2024
84e5db3
Merge remote-tracking branch 'upstream/main' into event-notes
kqualters-elastic Jun 26, 2024
7a5d545
Undo unneeded test change
kqualters-elastic Jun 27, 2024
76a717c
Merge remote-tracking branch 'upstream/main' into event-notes
kqualters-elastic Jun 27, 2024
373af99
Add/update openapi schema
kqualters-elastic Jun 27, 2024
ea05f66
Merge remote-tracking branch 'upstream/main' into event-notes
kqualters-elastic Jun 27, 2024
469f97f
WIP using slice
kqualters-elastic Jun 28, 2024
00f5fff
Merge branch 'main' into notes-management-table
kqualters-elastic Jul 1, 2024
11490b4
WIP use rtk for management page
kqualters-elastic Jul 1, 2024
9a65498
Notes management table and tab
kqualters-elastic Jul 1, 2024
0b5e41e
- add page title
PhilippeOberti Jul 1, 2024
3e29f13
Add tests
kqualters-elastic Jul 1, 2024
b1097b6
Merge remote-tracking branch 'origin/notes-management-table' into not…
kqualters-elastic Jul 1, 2024
e60aa4d
Move notes page to timelines, register deep link conditionally
kqualters-elastic Jul 1, 2024
d461fb4
Merge remote-tracking branch 'upstream/main' into notes-management-table
kqualters-elastic Jul 1, 2024
a6fbb4e
Fix types
kqualters-elastic Jul 1, 2024
bab95c1
Merge remote-tracking branch 'upstream/main' into notes-management-table
kqualters-elastic Jul 1, 2024
8821356
Fix tests
kqualters-elastic Jul 1, 2024
56a42c6
Merge remote-tracking branch 'upstream/main' into notes-management-table
kqualters-elastic Jul 1, 2024
ec24830
Fix test and remove unneeded selector
kqualters-elastic Jul 1, 2024
ae8c7d1
Merge remote-tracking branch 'upstream/main' into notes-management-table
kqualters-elastic Jul 1, 2024
c15c13f
Merge branch 'main' into notes-management-table
kqualters-elastic Jul 1, 2024
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 @@ -504,10 +504,9 @@ export const mockGlobalState: State = {
discover: getMockDiscoverInTimelineState(),
dataViewPicker: dataViewPickerInitialState,
notes: {
ids: ['1'],
entities: {
'1': {
eventId: 'event-id',
eventId: 'document-id-1',
noteId: '1',
note: 'note-1',
timelineId: 'timeline-1',
Expand All @@ -518,15 +517,31 @@ export const mockGlobalState: State = {
version: 'version',
},
},
ids: ['1'],
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
deleteNotes: ReqStatus.Idle,
fetchNotes: ReqStatus.Idle,
},
error: {
fetchNotesByDocumentIds: null,
createNote: null,
deleteNote: null,
deleteNotes: null,
fetchNotes: null,
},
pagination: {
page: 1,
perPage: 10,
total: 0,
},
sort: {
field: 'created' as const,
direction: 'desc' as const,
},
filter: '',
search: '',
selectedIds: [],
pendingDeleteIds: [],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ describe('NotesList', () => {
...mockGlobalState.notes,
status: {
...mockGlobalState.notes.status,
deleteNote: ReqStatus.Loading,
deleteNotes: ReqStatus.Loading,
},
},
});
Expand All @@ -217,11 +217,11 @@ describe('NotesList', () => {
...mockGlobalState.notes,
status: {
...mockGlobalState.notes.status,
deleteNote: ReqStatus.Failed,
deleteNotes: ReqStatus.Failed,
},
error: {
...mockGlobalState.notes.error,
deleteNote: { type: 'http', status: 500 },
deleteNotes: { type: 'http', status: 500 },
},
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ import {
import type { State } from '../../../../common/store';
import type { Note } from '../../../../../common/api/timeline';
import {
deleteNote,
deleteNotes,
ReqStatus,
selectCreateNoteStatus,
selectDeleteNoteError,
selectDeleteNoteStatus,
selectDeleteNotesError,
selectDeleteNotesStatus,
selectFetchNotesByDocumentIdsError,
selectFetchNotesByDocumentIdsStatus,
selectNotesByDocumentId,
Expand Down Expand Up @@ -91,14 +91,14 @@ export const NotesList = memo(({ eventId }: NotesListProps) => {

const createStatus = useSelector((state: State) => selectCreateNoteStatus(state));

const deleteStatus = useSelector((state: State) => selectDeleteNoteStatus(state));
const deleteError = useSelector((state: State) => selectDeleteNoteError(state));
const deleteStatus = useSelector((state: State) => selectDeleteNotesStatus(state));
const deleteError = useSelector((state: State) => selectDeleteNotesError(state));
const [deletingNoteId, setDeletingNoteId] = useState('');

const deleteNoteFc = useCallback(
(noteId: string) => {
setDeletingNoteId(noteId);
dispatch(deleteNote({ id: noteId }));
dispatch(deleteNotes({ ids: [noteId] }));
},
[dispatch]
);
Expand Down
38 changes: 35 additions & 3 deletions x-pack/plugins/security_solution/public/notes/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,38 @@ export const createNote = async ({ note }: { note: BareNote }) => {
}
};

export const fetchNotes = async ({
page,
perPage,
sortField,
sortOrder,
filter,
search,
}: {
page: number;
perPage: number;
sortField: string;
sortOrder: string;
filter: string;
search: string;
}) => {
const response = await KibanaServices.get().http.get<{ totalCount: number; notes: Note[] }>(
NOTE_URL,
{
query: {
page,
perPage,
sortField,
sortOrder,
filter,
search,
},
version: '2023-10-31',
}
);
return response;
};

/**
* Fetches all the notes for an array of document ids
*/
Expand All @@ -44,11 +76,11 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => {
};

/**
* Deletes a note
* Deletes multiple notes
*/
export const deleteNote = async (noteId: string) => {
export const deleteNotes = async (noteIds: string[]) => {
const response = await KibanaServices.get().http.delete<{ data: unknown }>(NOTE_URL, {
body: JSON.stringify({ noteId }),
body: JSON.stringify({ noteIds }),
version: '2023-10-31',
});
return response;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { EuiConfirmModal } from '@elastic/eui';
import * as i18n from './translations';
import {
deleteNotes,
userClosedDeleteModal,
selectNotesTablePendingDeleteIds,
selectDeleteNotesStatus,
ReqStatus,
} from '..';

export const DeleteConfirmModal = React.memo(() => {
const dispatch = useDispatch();
const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds);
const deleteNotesStatus = useSelector(selectDeleteNotesStatus);
const deleteLoading = deleteNotesStatus === ReqStatus.Loading;

const onCancel = useCallback(() => {
dispatch(userClosedDeleteModal());
}, [dispatch]);

const onConfirm = useCallback(() => {
dispatch(deleteNotes({ ids: pendingDeleteIds }));
}, [dispatch, pendingDeleteIds]);

return (
<EuiConfirmModal
aria-labelledby={'delete-notes-modal'}
title={i18n.DELETE_NOTES_MODAL_TITLE}
onCancel={onCancel}
onConfirm={onConfirm}
isLoading={deleteLoading}
cancelButtonText={i18n.DELETE_NOTES_CANCEL}
confirmButtonText={i18n.DELETE}
buttonColor="danger"
defaultFocusedButton="confirm"
>
{i18n.DELETE_NOTES_CONFIRM(pendingDeleteIds.length)}
</EuiConfirmModal>
);
});

DeleteConfirmModal.displayName = 'DeleteConfirmModal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui';
import React, { useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { userSearchedNotes, selectNotesTableSearch } from '..';

const SearchRowContainer = styled.div`
&:not(:last-child) {
margin-bottom: ${(props) => props.theme.eui.euiSizeL};
}
`;

SearchRowContainer.displayName = 'SearchRowContainer';

const SearchRowFlexGroup = styled(EuiFlexGroup)`
margin-bottom: ${(props) => props.theme.eui.euiSizeXS};
`;

SearchRowFlexGroup.displayName = 'SearchRowFlexGroup';

export const SearchRow = React.memo(() => {
const dispatch = useDispatch();
const searchBox = useMemo(
() => ({
placeholder: 'Search note contents',
incremental: false,
'data-test-subj': 'notes-search-bar',
}),
[]
);

const notesSearch = useSelector(selectNotesTableSearch);

const onQueryChange = useCallback(
({ queryText }) => {
dispatch(userSearchedNotes(queryText.trim()));
},
[dispatch]
);

return (
<SearchRowContainer>
<SearchRowFlexGroup gutterSize="s">
<EuiFlexItem>
<EuiSearchBar
box={searchBox}
onChange={onQueryChange}
query={notesSearch}
defaultQuery={''}
/>
</EuiFlexItem>
</SearchRowFlexGroup>
</SearchRowContainer>
);
});

SearchRow.displayName = 'SearchRow';
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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 { i18n } from '@kbn/i18n';

export const BATCH_ACTIONS = i18n.translate(
'xpack.securitySolution.notes.management.batchActionsTitle',
{
defaultMessage: 'Bulk actions',
}
);

export const CREATED_COLUMN = i18n.translate(
'xpack.securitySolution.notes.management.createdColumnTitle',
{
defaultMessage: 'Created',
}
);

export const CREATED_BY_COLUMN = i18n.translate(
'xpack.securitySolution.notes.management.createdByColumnTitle',
{
defaultMessage: 'Created by',
}
);

export const EVENT_ID_COLUMN = i18n.translate(
'xpack.securitySolution.notes.management.eventIdColumnTitle',
{
defaultMessage: 'Document ID',
}
);

export const TIMELINE_ID_COLUMN = i18n.translate(
'xpack.securitySolution.notes.management.timelineIdColumnTitle',
{
defaultMessage: 'Timeline ID',
}
);

export const NOTE_CONTENT_COLUMN = i18n.translate(
'xpack.securitySolution.notes.management.noteContentColumnTitle',
{
defaultMessage: 'Note content',
}
);

export const DELETE = i18n.translate('xpack.securitySolution.notes.management.deleteAction', {
defaultMessage: 'Delete',
});

export const DELETE_SINGLE_NOTE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.notes.management.deleteDescription',
{
defaultMessage: 'Delete this note',
}
);

export const NOTES_MANAGEMENT_TITLE = i18n.translate(
'xpack.securitySolution.notes.management.pageTitle',
{
defaultMessage: 'Notes management',
}
);

export const TABLE_ERROR = i18n.translate('xpack.securitySolution.notes.management.tableError', {
defaultMessage: 'Unable to load notes',
});

export const DELETE_NOTES_MODAL_TITLE = i18n.translate(
'xpack.securitySolution.notes.management.deleteNotesModalTitle',
{
defaultMessage: 'Delete notes?',
}
);

export const DELETE_NOTES_CONFIRM = (selectedNotes: number) =>
i18n.translate('xpack.securitySolution.notes.management.deleteNotesConfirm', {
values: { selectedNotes },
defaultMessage:
'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, one {note} other {notes}}?',
});

export const DELETE_NOTES_CANCEL = i18n.translate(
'xpack.securitySolution.notes.management.deleteNotesCancel',
{
defaultMessage: 'Cancel',
}
);

export const DELETE_SELECTED = i18n.translate(
'xpack.securitySolution.notes.management.deleteSelected',
{
defaultMessage: 'Delete selected notes',
}
);

export const REFRESH = i18n.translate('xpack.securitySolution.notes.management.refresh', {
defaultMessage: 'Refresh',
});
Loading