[Security Solution][Timeline] Notes management table (#187214)

This commit is contained in:
Kevin Qualters 2024-07-02 03:51:28 -04:00 committed by GitHub
parent 0da7bbe318
commit 1fa094b7ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1414 additions and 370 deletions

View file

@ -9,7 +9,7 @@ import { isEmpty } from 'lodash/fp';
import type { TimelineTypeLiteral } from '../../../../common/api/timeline';
import { appendSearch } from './helpers';
export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral, search?: string) =>
export const getTimelineTabsUrl = (tabName: TimelineTypeLiteral | 'notes', search?: string) =>
`/${tabName}${appendSearch(search)}`;
export const getTimelineUrl = (id: string, graphEventId?: string) =>

View file

@ -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',
@ -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: [],
},
};

View file

@ -41,7 +41,7 @@ jest.mock('react-redux', () => {
const renderNotesList = () =>
render(
<TestProviders>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -69,7 +69,7 @@ describe('NotesList', () => {
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -115,7 +115,7 @@ describe('NotesList', () => {
render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -131,7 +131,7 @@ describe('NotesList', () => {
...mockGlobalState.notes,
entities: {
'1': {
eventId: 'event-id',
eventId: 'document-id-1',
noteId: '1',
note: 'note-1',
timelineId: '',
@ -147,7 +147,7 @@ describe('NotesList', () => {
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`));
@ -169,7 +169,7 @@ describe('NotesList', () => {
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -196,14 +196,14 @@ describe('NotesList', () => {
...mockGlobalState.notes,
status: {
...mockGlobalState.notes.status,
deleteNote: ReqStatus.Loading,
deleteNotes: ReqStatus.Loading,
},
},
});
const { getByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -217,18 +217,18 @@ 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 },
},
},
});
render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);
@ -261,7 +261,7 @@ describe('NotesList', () => {
...mockGlobalState.notes,
entities: {
'1': {
eventId: 'event-id',
eventId: 'document-id-1',
noteId: '1',
note: 'note-1',
timelineId: '',
@ -277,7 +277,7 @@ describe('NotesList', () => {
const { queryByTestId } = render(
<TestProviders store={store}>
<NotesList eventId={'event-id'} />
<NotesList eventId={'document-id-1'} />
</TestProviders>
);

View file

@ -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,
@ -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]
);

View file

@ -22,7 +22,6 @@ import {
EVENT_FILTERS_PATH,
HOST_ISOLATION_EXCEPTIONS_PATH,
MANAGE_PATH,
NOTES_MANAGEMENT_PATH,
POLICIES_PATH,
RESPONSE_ACTIONS_HISTORY_PATH,
SecurityPageName,
@ -40,7 +39,6 @@ import {
TRUSTED_APPLICATIONS,
ENTITY_ANALYTICS_RISK_SCORE,
ASSET_CRITICALITY,
NOTES,
} from '../app/translations';
import { licenseService } from '../common/hooks/use_license';
import type { LinkItem } from '../common/links/types';
@ -87,12 +85,6 @@ const categories = [
}),
linkIds: [SecurityPageName.cloudDefendPolicies],
},
{
label: i18n.translate('xpack.securitySolution.appLinks.category.investigations', {
defaultMessage: 'Investigations',
}),
linkIds: [SecurityPageName.notesManagement],
},
];
export const links: LinkItem = {
@ -223,18 +215,6 @@ export const links: LinkItem = {
hideTimeline: true,
},
cloudDefendLink,
{
id: SecurityPageName.notesManagement,
title: NOTES,
description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', {
defaultMessage: 'Visualize and delete notes.',
}),
landingIcon: IconTool, // TODO get new icon
path: NOTES_MANAGEMENT_PATH,
skipUrlState: true,
hideTimeline: true,
experimentalKey: 'securitySolutionNotesEnabled',
},
],
};

View file

@ -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
*/
@ -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;

View file

@ -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';

View file

@ -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';

View file

@ -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',
});

View file

@ -0,0 +1,105 @@
/*
* 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, { useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { EuiContextMenuItem } from '@elastic/eui';
import {
UtilityBarGroup,
UtilityBarText,
UtilityBar,
UtilityBarSection,
UtilityBarAction,
} from '../../common/components/utility_bar';
import {
selectNotesPagination,
selectNotesTableSort,
fetchNotes,
selectNotesTableSelectedIds,
selectNotesTableSearch,
userSelectedBulkDelete,
} from '..';
import * as i18n from './translations';
export const NotesUtilityBar = React.memo(() => {
const dispatch = useDispatch();
const pagination = useSelector(selectNotesPagination);
const sort = useSelector(selectNotesTableSort);
const totalItems = pagination.total ?? 0;
const selectedItems = useSelector(selectNotesTableSelectedIds);
const resultsCount = useMemo(() => {
const { perPage, page } = pagination;
const startOfCurrentPage = perPage * (page - 1) + 1;
const endOfCurrentPage = Math.min(perPage * page, totalItems);
return perPage === 0 ? 'All' : `${startOfCurrentPage}-${endOfCurrentPage} of ${totalItems}`;
}, [pagination, totalItems]);
const deleteSelectedNotes = useCallback(() => {
dispatch(userSelectedBulkDelete());
}, [dispatch]);
const notesSearch = useSelector(selectNotesTableSearch);
const BulkActionPopoverContent = useCallback(() => {
return (
<EuiContextMenuItem
data-test-subj="notes-management-delete-notes"
onClick={deleteSelectedNotes}
disabled={selectedItems.length === 0}
icon="trash"
key="DeleteItemKey"
>
{i18n.DELETE_SELECTED}
</EuiContextMenuItem>
);
}, [deleteSelectedNotes, selectedItems.length]);
const refresh = useCallback(() => {
dispatch(
fetchNotes({
page: pagination.page,
perPage: pagination.perPage,
sortField: sort.field,
sortOrder: sort.direction,
filter: '',
search: notesSearch,
})
);
}, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]);
return (
<UtilityBar border>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText data-test-subj="notes-management-pagination-count">
{`Showing: ${resultsCount}`}
</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
<UtilityBarText data-test-subj="notes-management-selected-count">
{selectedItems.length > 0 ? `${selectedItems.length} selected` : ''}
</UtilityBarText>
<UtilityBarAction
dataTestSubj="notes-management-utility-bar-actions"
iconSide="right"
iconType="arrowDown"
popoverContent={BulkActionPopoverContent}
>
<span data-test-subj="notes-management-utility-bar-action-button">
{i18n.BATCH_ACTIONS}
</span>
</UtilityBarAction>
<UtilityBarAction
dataTestSubj="notes-management-utility-bar-refresh-button"
iconSide="right"
iconType="refresh"
onClick={refresh}
>
{i18n.REFRESH}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
);
});
NotesUtilityBar.displayName = 'NotesUtilityBar';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { NoteManagementPage } from './pages/note_management_page';
export * from './store/notes.slice';

View file

@ -5,14 +5,207 @@
* 2.0.
*/
import React from 'react';
import React, { useCallback, useMemo, useEffect } from 'react';
import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui';
import { EuiBasicTable, EuiEmptyPrompt } from '@elastic/eui';
import { useDispatch, useSelector } from 'react-redux';
// TODO unify this type from the api with the one in public/common/lib/note
import type { Note } from '../../../common/api/timeline';
import { FormattedRelativePreferenceDate } from '../../common/components/formatted_date';
import {
userSelectedPage,
userSelectedPerPage,
userSelectedRow,
userSortedNotes,
selectAllNotes,
selectNotesPagination,
selectNotesTableSort,
fetchNotes,
selectNotesTableSearch,
selectFetchNotesStatus,
selectNotesTablePendingDeleteIds,
userSelectedRowForDeletion,
selectFetchNotesError,
ReqStatus,
} from '..';
import type { NotesState } from '..';
import { SearchRow } from '../components/search_row';
import { NotesUtilityBar } from '../components/utility_bar';
import { DeleteConfirmModal } from '../components/delete_confirm_modal';
import * as i18n from '../components/translations';
const columns: Array<EuiBasicTableColumn<Note>> = [
{
field: 'created',
name: i18n.CREATED_COLUMN,
sortable: true,
render: (created: Note['created']) => <FormattedRelativePreferenceDate value={created} />,
},
{
field: 'createdBy',
name: i18n.CREATED_BY_COLUMN,
},
{
field: 'eventId',
name: i18n.EVENT_ID_COLUMN,
sortable: true,
},
{
field: 'timelineId',
name: i18n.TIMELINE_ID_COLUMN,
},
{
field: 'note',
name: i18n.NOTE_CONTENT_COLUMN,
},
];
const pageSizeOptions = [50, 25, 10, 0];
/**
* Page to allow users to manage notes. The page is accessible via the Investigations section within the Manage page.
* // TODO to be implemented
* Allows user to search and delete notes.
* This component uses the same slices of state as the notes functionality of the rest of the Security Solution applicaiton.
* Therefore, changes made in this page (like fetching or deleting notes) will have an impact everywhere.
*/
export const NoteManagementPage = () => {
return <></>;
const dispatch = useDispatch();
const notes = useSelector(selectAllNotes);
const pagination = useSelector(selectNotesPagination);
const sort = useSelector(selectNotesTableSort);
const totalItems = pagination.total ?? 0;
const notesSearch = useSelector(selectNotesTableSearch);
const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds);
const isDeleteModalVisible = pendingDeleteIds.length > 0;
const fetchNotesStatus = useSelector(selectFetchNotesStatus);
const fetchLoading = fetchNotesStatus === ReqStatus.Loading;
const fetchError = fetchNotesStatus === ReqStatus.Failed;
const fetchErrorData = useSelector(selectFetchNotesError);
const fetchData = useCallback(() => {
dispatch(
fetchNotes({
page: pagination.page,
perPage: pagination.perPage,
sortField: sort.field,
sortOrder: sort.direction,
filter: '',
search: notesSearch,
})
);
}, [dispatch, pagination.page, pagination.perPage, sort.field, sort.direction, notesSearch]);
useEffect(() => {
fetchData();
}, [fetchData]);
const onTableChange = useCallback(
({
page,
sort: newSort,
}: {
page?: { index: number; size: number };
sort?: NotesState['sort'];
}) => {
if (page) {
dispatch(userSelectedPage(page.index + 1));
dispatch(userSelectedPerPage(page.size));
}
if (newSort) {
dispatch(userSortedNotes({ field: newSort.field, direction: newSort.direction }));
}
},
[dispatch]
);
const selectRowForDeletion = useCallback(
(id: string) => {
dispatch(userSelectedRowForDeletion(id));
},
[dispatch]
);
const onSelectionChange = useCallback(
(selection: Note[]) => {
const rowIds = selection.map((item) => item.noteId);
dispatch(userSelectedRow(rowIds));
},
[dispatch]
);
const itemIdSelector = useCallback((item: Note) => {
return item.noteId;
}, []);
const columnWithActions = useMemo(() => {
const actions: Array<DefaultItemAction<Note>> = [
{
name: i18n.DELETE,
description: i18n.DELETE_SINGLE_NOTE_DESCRIPTION,
color: 'primary',
icon: 'trash',
type: 'icon',
onClick: (note: Note) => selectRowForDeletion(note.noteId),
},
];
return [
...columns,
{
name: 'actions',
actions,
},
];
}, [selectRowForDeletion]);
const currentPagination = useMemo(() => {
return {
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
totalItemCount: totalItems,
pageSizeOptions,
};
}, [pagination, totalItems]);
const selection = useMemo(() => {
return {
onSelectionChange,
selectable: () => true,
};
}, [onSelectionChange]);
const sorting: { sort: { field: keyof Note; direction: 'asc' | 'desc' } } = useMemo(() => {
return {
sort,
};
}, [sort]);
if (fetchError) {
return (
<EuiEmptyPrompt
iconType="error"
color="danger"
title={<h2>{i18n.TABLE_ERROR}</h2>}
body={<p>{fetchErrorData}</p>}
/>
);
}
return (
<>
<SearchRow />
<NotesUtilityBar />
<EuiBasicTable
items={notes}
pagination={currentPagination}
columns={columnWithActions}
onChange={onTableChange}
selection={selection}
sorting={sorting}
itemId={itemIdSelector}
loading={fetchLoading}
/>
{isDeleteModalVisible && <DeleteConfirmModal />}
</>
);
};
NoteManagementPage.displayName = 'NoteManagementPage';

View file

@ -5,113 +5,137 @@
* 2.0.
*/
import * as uuid from 'uuid';
import { miniSerializeError } from '@reduxjs/toolkit';
import type { SerializedError } from '@reduxjs/toolkit';
import {
createNote,
deleteNote,
deleteNotes,
fetchNotesByDocumentIds,
fetchNotes,
initialNotesState,
notesReducer,
ReqStatus,
selectAllNotes,
selectCreateNoteError,
selectCreateNoteStatus,
selectDeleteNoteError,
selectDeleteNoteStatus,
selectDeleteNotesError,
selectDeleteNotesStatus,
selectFetchNotesByDocumentIdsError,
selectFetchNotesByDocumentIdsStatus,
selectFetchNotesError,
selectFetchNotesStatus,
selectNoteById,
selectNoteIds,
selectNotesByDocumentId,
selectNotesPagination,
selectNotesTablePendingDeleteIds,
selectNotesTableSearch,
selectNotesTableSelectedIds,
selectNotesTableSort,
userClosedDeleteModal,
userFilteredNotes,
userSearchedNotes,
userSelectedBulkDelete,
userSelectedPage,
userSelectedPerPage,
userSelectedRow,
userSelectedRowForDeletion,
userSortedNotes,
} from './notes.slice';
import type { NotesState } from './notes.slice';
import { mockGlobalState } from '../../common/mock';
import type { Note } from '../../../common/api/timeline';
const initalEmptyState = initialNotesState;
export const generateNoteMock = (documentIds: string[]) =>
documentIds.map((documentId: string) => ({
noteId: uuid.v4(),
version: 'WzU1MDEsMV0=',
timelineId: '',
eventId: documentId,
note: 'This is a mocked note',
created: new Date().getTime(),
createdBy: 'elastic',
updated: new Date().getTime(),
updatedBy: 'elastic',
}));
const generateNoteMock = (documentId: string): Note => ({
noteId: uuid.v4(),
version: 'WzU1MDEsMV0=',
timelineId: '',
eventId: documentId,
note: 'This is a mocked note',
created: new Date().getTime(),
createdBy: 'elastic',
updated: new Date().getTime(),
updatedBy: 'elastic',
});
const mockNote1 = generateNoteMock('1');
const mockNote2 = generateNoteMock('2');
const mockNote = { ...generateNoteMock(['1'])[0] };
const initialNonEmptyState = {
entities: {
[mockNote.noteId]: mockNote,
[mockNote1.noteId]: mockNote1,
[mockNote2.noteId]: mockNote2,
},
ids: [mockNote.noteId],
ids: [mockNote1.noteId, mockNote2.noteId],
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
deleteNotes: ReqStatus.Idle,
fetchNotes: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
error: { fetchNotesByDocumentIds: null, createNote: 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: [],
};
describe('notesSlice', () => {
describe('notesReducer', () => {
it('should handle an unknown action and return the initial state', () => {
expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual({
entities: {},
ids: [],
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
expect(notesReducer(initalEmptyState, { type: 'unknown' })).toEqual(initalEmptyState);
});
describe('fetchNotesByDocumentIds', () => {
it('should set correct status state when fetching notes by document id', () => {
it('should set correct status state when fetching notes by document ids', () => {
const action = { type: fetchNotesByDocumentIds.pending.type };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
...initalEmptyState.status,
fetchNotesByDocumentIds: ReqStatus.Loading,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should set correct state when success on fetch notes by document id on an empty state', () => {
it('should set correct state when success on fetch notes by document ids on an empty state', () => {
const action = {
type: fetchNotesByDocumentIds.fulfilled.type,
payload: {
entities: {
notes: {
[mockNote.noteId]: mockNote,
[mockNote1.noteId]: mockNote1,
},
},
result: [mockNote.noteId],
result: [mockNote1.noteId],
},
};
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
entities: action.payload.entities.notes,
ids: action.payload.result,
status: {
...initalEmptyState.status,
fetchNotesByDocumentIds: ReqStatus.Succeeded,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should replace notes when success on fetch notes by document id on a non-empty state', () => {
const newMockNote = { ...mockNote, timelineId: 'timelineId' };
it('should replace notes when success on fetch notes by document ids on a non-empty state', () => {
const newMockNote = { ...mockNote1, timelineId: 'timelineId' };
const action = {
type: fetchNotesByDocumentIds.fulfilled.type,
payload: {
@ -125,173 +149,336 @@ describe('notesSlice', () => {
};
expect(notesReducer(initialNonEmptyState, action)).toEqual({
entities: action.payload.entities.notes,
ids: action.payload.result,
status: {
fetchNotesByDocumentIds: ReqStatus.Succeeded,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
...initalEmptyState,
entities: {
[newMockNote.noteId]: newMockNote,
[mockNote2.noteId]: mockNote2,
},
ids: [newMockNote.noteId, mockNote2.noteId],
status: {
...initalEmptyState.status,
fetchNotesByDocumentIds: ReqStatus.Succeeded,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should set correct error state when failing to fetch notes by document id', () => {
it('should set correct error state when failing to fetch notes by document ids', () => {
const action = { type: fetchNotesByDocumentIds.rejected.type, error: 'error' };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
...initalEmptyState.status,
fetchNotesByDocumentIds: ReqStatus.Failed,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Idle,
},
error: {
...initalEmptyState.error,
fetchNotesByDocumentIds: 'error',
createNote: null,
deleteNote: null,
},
});
});
});
describe('createNote', () => {
it('should set correct status state when creating a note by document id', () => {
it('should set correct status state when creating a note', () => {
const action = { type: createNote.pending.type };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
...initalEmptyState.status,
createNote: ReqStatus.Loading,
deleteNote: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should set correct state when success on create a note by document id on an empty state', () => {
it('should set correct state when success on create a note on an empty state', () => {
const action = {
type: createNote.fulfilled.type,
payload: {
entities: {
notes: {
[mockNote.noteId]: mockNote,
[mockNote1.noteId]: mockNote1,
},
},
result: mockNote.noteId,
result: mockNote1.noteId,
},
};
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
entities: action.payload.entities.notes,
ids: [action.payload.result],
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
...initalEmptyState.status,
createNote: ReqStatus.Succeeded,
deleteNote: ReqStatus.Idle,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should set correct error state when failing to create a note by document id', () => {
it('should set correct error state when failing to create a note', () => {
const action = { type: createNote.rejected.type, error: 'error' };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
...initalEmptyState.status,
createNote: ReqStatus.Failed,
deleteNote: ReqStatus.Idle,
},
error: {
fetchNotesByDocumentIds: null,
...initalEmptyState.error,
createNote: 'error',
deleteNote: null,
},
});
});
});
describe('deleteNote', () => {
it('should set correct status state when deleting a note', () => {
const action = { type: deleteNote.pending.type };
describe('deleteNotes', () => {
it('should set correct status state when deleting notes', () => {
const action = { type: deleteNotes.pending.type };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Loading,
...initalEmptyState.status,
deleteNotes: ReqStatus.Loading,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
});
});
it('should set correct state when success on deleting a note', () => {
it('should set correct state when success on deleting notes', () => {
const action = {
type: deleteNote.fulfilled.type,
payload: mockNote.noteId,
type: deleteNotes.fulfilled.type,
payload: [mockNote1.noteId],
};
const state = {
...initialNonEmptyState,
pendingDeleteIds: [mockNote1.noteId],
};
expect(notesReducer(initialNonEmptyState, action)).toEqual({
entities: {},
ids: [],
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Succeeded,
expect(notesReducer(state, action)).toEqual({
...initialNonEmptyState,
entities: {
[mockNote2.noteId]: mockNote2,
},
error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null },
ids: [mockNote2.noteId],
status: {
...initialNonEmptyState.status,
deleteNotes: ReqStatus.Succeeded,
},
pendingDeleteIds: [],
});
});
it('should set correct state when failing to create a note by document id', () => {
const action = { type: deleteNote.rejected.type, error: 'error' };
it('should set correct state when failing to delete notes', () => {
const action = { type: deleteNotes.rejected.type, error: 'error' };
expect(notesReducer(initalEmptyState, action)).toEqual({
entities: {},
ids: [],
...initalEmptyState,
status: {
fetchNotesByDocumentIds: ReqStatus.Idle,
createNote: ReqStatus.Idle,
deleteNote: ReqStatus.Failed,
...initalEmptyState.status,
deleteNotes: ReqStatus.Failed,
},
error: {
fetchNotesByDocumentIds: null,
createNote: null,
deleteNote: 'error',
...initalEmptyState.error,
deleteNotes: 'error',
},
});
});
it('should set correct status when fetching notes', () => {
const action = { type: fetchNotes.pending.type };
expect(notesReducer(initialNotesState, action)).toEqual({
...initialNotesState,
status: {
...initialNotesState.status,
fetchNotes: ReqStatus.Loading,
},
});
});
it('should set notes and update pagination when fetch is successful', () => {
const action = {
type: fetchNotes.fulfilled.type,
payload: {
entities: {
notes: { [mockNote2.noteId]: mockNote2, '2': { ...mockNote2, noteId: '2' } },
},
totalCount: 2,
},
};
const state = notesReducer(initialNotesState, action);
expect(state.entities).toEqual(action.payload.entities.notes);
expect(state.ids).toHaveLength(2);
expect(state.pagination.total).toBe(2);
expect(state.status.fetchNotes).toBe(ReqStatus.Succeeded);
});
it('should set error when fetch fails', () => {
const action = { type: fetchNotes.rejected.type, error: { message: 'Failed to fetch' } };
const state = notesReducer(initialNotesState, action);
expect(state.status.fetchNotes).toBe(ReqStatus.Failed);
expect(state.error.fetchNotes).toEqual({ message: 'Failed to fetch' });
});
it('should set correct status when deleting multiple notes', () => {
const action = { type: deleteNotes.pending.type };
expect(notesReducer(initialNotesState, action)).toEqual({
...initialNotesState,
status: {
...initialNotesState.status,
deleteNotes: ReqStatus.Loading,
},
});
});
it('should remove multiple notes when delete is successful', () => {
const initialState = {
...initialNotesState,
entities: { '1': mockNote1, '2': { ...mockNote2, noteId: '2' } },
ids: ['1', '2'],
};
const action = { type: deleteNotes.fulfilled.type, payload: ['1', '2'] };
const state = notesReducer(initialState, action);
expect(state.entities).toEqual({});
expect(state.ids).toHaveLength(0);
expect(state.status.deleteNotes).toBe(ReqStatus.Succeeded);
});
it('should set error when delete fails', () => {
const action = { type: deleteNotes.rejected.type, error: { message: 'Failed to delete' } };
const state = notesReducer(initialNotesState, action);
expect(state.status.deleteNotes).toBe(ReqStatus.Failed);
expect(state.error.deleteNotes).toEqual({ message: 'Failed to delete' });
});
});
describe('userSelectedPage', () => {
it('should set correct value for the selected page', () => {
const action = { type: userSelectedPage.type, payload: 2 };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
pagination: {
...initalEmptyState.pagination,
page: 2,
},
});
});
});
describe('userSelectedPerPage', () => {
it('should set correct value for number of notes per page', () => {
const action = { type: userSelectedPerPage.type, payload: 25 };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
pagination: {
...initalEmptyState.pagination,
perPage: 25,
},
});
});
});
describe('userSortedNotes', () => {
it('should set correct value for sorting notes', () => {
const action = { type: userSortedNotes.type, payload: { field: 'note', direction: 'asc' } };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
sort: {
field: 'note',
direction: 'asc',
},
});
});
});
describe('userFilteredNotes', () => {
it('should set correct value to filter notes', () => {
const action = { type: userFilteredNotes.type, payload: 'abc' };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
filter: 'abc',
});
});
});
describe('userSearchedNotes', () => {
it('should set correct value to search notes', () => {
const action = { type: userSearchedNotes.type, payload: 'abc' };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
search: 'abc',
});
});
});
describe('userSelectedRow', () => {
it('should set correct ids for selected rows', () => {
const action = { type: userSelectedRow.type, payload: ['1'] };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
selectedIds: ['1'],
});
});
});
describe('userClosedDeleteModal', () => {
it('should reset pendingDeleteIds when closing modal', () => {
const action = { type: userClosedDeleteModal.type };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
pendingDeleteIds: [],
});
});
});
describe('userSelectedRowForDeletion', () => {
it('should set correct id when user selects a row', () => {
const action = { type: userSelectedRowForDeletion.type, payload: '1' };
expect(notesReducer(initalEmptyState, action)).toEqual({
...initalEmptyState,
pendingDeleteIds: ['1'],
});
});
});
describe('userSelectedBulkDelete', () => {
it('should update pendingDeleteIds when user chooses bulk delete', () => {
const action = { type: userSelectedBulkDelete.type };
const state = {
...initalEmptyState,
selectedIds: ['1'],
};
expect(notesReducer(state, action)).toEqual({
...state,
pendingDeleteIds: ['1'],
});
});
});
});
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]);
expect(selectAllNotes(mockGlobalState)).toEqual(
Object.values(mockGlobalState.notes.entities)
);
});
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);
expect(selectNoteById(mockGlobalState, '1')).toEqual(mockGlobalState.notes.entities['1']);
});
it('should return note ids', () => {
const state = mockGlobalState;
state.notes.entities = initialNonEmptyState.entities;
state.notes.ids = initialNonEmptyState.ids;
expect(selectNoteIds(state)).toEqual([mockNote.noteId]);
expect(selectNoteIds(mockGlobalState)).toEqual(['1']);
});
it('should return fetch notes by document id status', () => {
@ -311,19 +498,110 @@ describe('notesSlice', () => {
});
it('should return delete note status', () => {
expect(selectDeleteNoteStatus(mockGlobalState)).toEqual(ReqStatus.Idle);
expect(selectDeleteNotesStatus(mockGlobalState)).toEqual(ReqStatus.Idle);
});
it('should return delete note error', () => {
expect(selectDeleteNoteError(mockGlobalState)).toEqual(null);
expect(selectDeleteNotesError(mockGlobalState)).toEqual(null);
});
it('should return all notes for an existing document id', () => {
expect(selectNotesByDocumentId(mockGlobalState, '1')).toEqual([mockNote]);
expect(selectNotesByDocumentId(mockGlobalState, 'document-id-1')).toEqual([
mockGlobalState.notes.entities['1'],
]);
});
it('should return no notes if document id does not exist', () => {
expect(selectNotesByDocumentId(mockGlobalState, '2')).toHaveLength(0);
expect(selectNotesByDocumentId(mockGlobalState, 'wrong-document-id')).toHaveLength(0);
});
it('should select notes pagination', () => {
const state = {
...mockGlobalState,
notes: { ...initialNotesState, pagination: { page: 2, perPage: 20, total: 100 } },
};
expect(selectNotesPagination(state)).toEqual({ page: 2, perPage: 20, total: 100 });
});
it('should select notes table sort', () => {
const notes: NotesState = {
...initialNotesState,
sort: { field: 'created', direction: 'asc' },
};
const state = {
...mockGlobalState,
notes,
};
expect(selectNotesTableSort(state)).toEqual({ field: 'created', direction: 'asc' });
});
it('should select notes table total items', () => {
const state = {
...mockGlobalState,
notes: {
...initialNotesState,
pagination: { ...initialNotesState.pagination, total: 100 },
},
};
expect(selectNotesPagination(state).total).toBe(100);
});
it('should select notes table selected ids', () => {
const state = {
...mockGlobalState,
notes: { ...initialNotesState, selectedIds: ['1', '2'] },
};
expect(selectNotesTableSelectedIds(state)).toEqual(['1', '2']);
});
it('should select notes table search', () => {
const state = { ...mockGlobalState, notes: { ...initialNotesState, search: 'test search' } };
expect(selectNotesTableSearch(state)).toBe('test search');
});
it('should select notes table pending delete ids', () => {
const state = {
...mockGlobalState,
notes: { ...initialNotesState, pendingDeleteIds: ['1', '2'] },
};
expect(selectNotesTablePendingDeleteIds(state)).toEqual(['1', '2']);
});
it('should select delete notes status', () => {
const state = {
...mockGlobalState,
notes: {
...initialNotesState,
status: { ...initialNotesState.status, deleteNotes: ReqStatus.Loading },
},
};
expect(selectDeleteNotesStatus(state)).toBe(ReqStatus.Loading);
});
it('should select fetch notes error', () => {
const error = new Error('Error fetching notes');
const reudxToolKItError = miniSerializeError(error);
const notes: NotesState = {
...initialNotesState,
error: { ...initialNotesState.error, fetchNotes: reudxToolKItError },
};
const state = {
...mockGlobalState,
notes,
};
const selectedError = selectFetchNotesError(state) as SerializedError;
expect(selectedError.message).toBe('Error fetching notes');
});
it('should select fetch notes status', () => {
const state = {
...mockGlobalState,
notes: {
...initialNotesState,
status: { ...initialNotesState.status, fetchNotes: ReqStatus.Succeeded },
},
};
expect(selectFetchNotesStatus(state)).toBe(ReqStatus.Succeeded);
});
});
});

View file

@ -11,7 +11,8 @@ import { createSelector } from 'reselect';
import type { State } from '../../common/store';
import {
createNote as createNoteApi,
deleteNote as deleteNoteApi,
deleteNotes as deleteNotesApi,
fetchNotes as fetchNotesApi,
fetchNotesByDocumentIds as fetchNotesByDocumentIdsApi,
} from '../api/api';
import type { NormalizedEntities, NormalizedEntity } from './normalize';
@ -34,13 +35,28 @@ export interface NotesState extends EntityState<Note> {
status: {
fetchNotesByDocumentIds: ReqStatus;
createNote: ReqStatus;
deleteNote: ReqStatus;
deleteNotes: ReqStatus;
fetchNotes: ReqStatus;
};
error: {
fetchNotesByDocumentIds: SerializedError | HttpError | null;
createNote: SerializedError | HttpError | null;
deleteNote: SerializedError | HttpError | null;
deleteNotes: SerializedError | HttpError | null;
fetchNotes: SerializedError | HttpError | null;
};
pagination: {
page: number;
perPage: number;
total: number;
};
sort: {
field: keyof Note;
direction: 'asc' | 'desc';
};
filter: string;
search: string;
selectedIds: string[];
pendingDeleteIds: string[];
}
const notesAdapter = createEntityAdapter<Note>({
@ -51,13 +67,28 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({
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',
direction: 'desc',
},
filter: '',
search: '',
selectedIds: [],
pendingDeleteIds: [],
});
export const fetchNotesByDocumentIds = createAsyncThunk<
@ -70,6 +101,23 @@ export const fetchNotesByDocumentIds = createAsyncThunk<
return normalizeEntities(res.notes);
});
export const fetchNotes = createAsyncThunk<
NormalizedEntities<Note> & { totalCount: number },
{
page: number;
perPage: number;
sortField: string;
sortOrder: string;
filter: string;
search: string;
},
{}
>('notes/fetchNotes', async (args) => {
const { page, perPage, sortField, sortOrder, filter, search } = args;
const res = await fetchNotesApi({ page, perPage, sortField, sortOrder, filter, search });
return { ...normalizeEntities(res.notes), totalCount: res.totalCount };
});
export const createNote = createAsyncThunk<NormalizedEntity<Note>, { note: BareNote }, {}>(
'notes/createNote',
async (args) => {
@ -79,19 +127,50 @@ export const createNote = createAsyncThunk<NormalizedEntity<Note>, { note: BareN
}
);
export const deleteNote = createAsyncThunk<string, { id: string }, {}>(
'notes/deleteNote',
export const deleteNotes = createAsyncThunk<string[], { ids: string[] }, {}>(
'notes/deleteNotes',
async (args) => {
const { id } = args;
await deleteNoteApi(id);
return id;
const { ids } = args;
await deleteNotesApi(ids);
return ids;
}
);
const notesSlice = createSlice({
name: 'notes',
initialState: initialNotesState,
reducers: {},
reducers: {
userSelectedPage: (state, action: { payload: number }) => {
state.pagination.page = action.payload;
},
userSelectedPerPage: (state, action: { payload: number }) => {
state.pagination.perPage = action.payload;
},
userSortedNotes: (
state,
action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } }
) => {
state.sort = action.payload;
},
userFilteredNotes: (state, action: { payload: string }) => {
state.filter = action.payload;
},
userSearchedNotes: (state, action: { payload: string }) => {
state.search = action.payload;
},
userSelectedRow: (state, action: { payload: string[] }) => {
state.selectedIds = action.payload;
},
userClosedDeleteModal: (state) => {
state.pendingDeleteIds = [];
},
userSelectedRowForDeletion: (state, action: { payload: string }) => {
state.pendingDeleteIds = [action.payload];
},
userSelectedBulkDelete: (state) => {
state.pendingDeleteIds = state.selectedIds;
},
},
extraReducers(builder) {
builder
.addCase(fetchNotesByDocumentIds.pending, (state) => {
@ -116,16 +195,32 @@ const notesSlice = createSlice({
state.status.createNote = ReqStatus.Failed;
state.error.createNote = action.payload ?? action.error;
})
.addCase(deleteNote.pending, (state) => {
state.status.deleteNote = ReqStatus.Loading;
.addCase(deleteNotes.pending, (state) => {
state.status.deleteNotes = ReqStatus.Loading;
})
.addCase(deleteNote.fulfilled, (state, action) => {
notesAdapter.removeOne(state, action.payload);
state.status.deleteNote = ReqStatus.Succeeded;
.addCase(deleteNotes.fulfilled, (state, action) => {
notesAdapter.removeMany(state, action.payload);
state.status.deleteNotes = ReqStatus.Succeeded;
state.pendingDeleteIds = state.pendingDeleteIds.filter(
(value) => !action.payload.includes(value)
);
})
.addCase(deleteNote.rejected, (state, action) => {
state.status.deleteNote = ReqStatus.Failed;
state.error.deleteNote = action.payload ?? action.error;
.addCase(deleteNotes.rejected, (state, action) => {
state.status.deleteNotes = ReqStatus.Failed;
state.error.deleteNotes = action.payload ?? action.error;
})
.addCase(fetchNotes.pending, (state) => {
state.status.fetchNotes = ReqStatus.Loading;
})
.addCase(fetchNotes.fulfilled, (state, action) => {
notesAdapter.setAll(state, action.payload.entities.notes);
state.pagination.total = action.payload.totalCount;
state.status.fetchNotes = ReqStatus.Succeeded;
state.selectedIds = [];
})
.addCase(fetchNotes.rejected, (state, action) => {
state.status.fetchNotes = ReqStatus.Failed;
state.error.fetchNotes = action.payload ?? action.error;
});
},
});
@ -148,11 +243,37 @@ export const selectCreateNoteStatus = (state: State) => state.notes.status.creat
export const selectCreateNoteError = (state: State) => state.notes.error.createNote;
export const selectDeleteNoteStatus = (state: State) => state.notes.status.deleteNote;
export const selectDeleteNotesStatus = (state: State) => state.notes.status.deleteNotes;
export const selectDeleteNoteError = (state: State) => state.notes.error.deleteNote;
export const selectDeleteNotesError = (state: State) => state.notes.error.deleteNotes;
export const selectNotesPagination = (state: State) => state.notes.pagination;
export const selectNotesTableSort = (state: State) => state.notes.sort;
export const selectNotesTableSelectedIds = (state: State) => state.notes.selectedIds;
export const selectNotesTableSearch = (state: State) => state.notes.search;
export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds;
export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes;
export const selectFetchNotesStatus = (state: State) => state.notes.status.fetchNotes;
export const selectNotesByDocumentId = createSelector(
[selectAllNotes, (state, documentId) => documentId],
(notes, documentId) => notes.filter((note) => note.eventId === documentId)
);
export const {
userSelectedPage,
userSelectedPerPage,
userSortedNotes,
userFilteredNotes,
userSearchedNotes,
userSelectedRow,
userClosedDeleteModal,
userSelectedRowForDeletion,
userSelectedBulkDelete,
} = notesSlice.actions;

View file

@ -25,7 +25,7 @@ export const useEditTimelineBatchActions = ({
}: {
deleteTimelines?: DeleteTimelines;
selectedItems?: OpenTimelineResult[];
tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | undefined>;
tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | null>;
timelineType: TimelineType | null;
}) => {
const {

View file

@ -69,7 +69,7 @@ interface OwnProps<TCache = object> {
export type OpenTimelineOwnProps = OwnProps &
Pick<
OpenTimelineProps,
'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle'
'defaultPageSize' | 'title' | 'importDataModalToggle' | 'setImportDataModalToggle' | 'tabName'
>;
/** Returns a collection of selected timeline ids */
@ -130,6 +130,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
importDataModalToggle,
onOpenTimeline,
setImportDataModalToggle,
tabName,
title,
}) => {
const dispatch = useDispatch();
@ -305,12 +306,16 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
/** Invoked by the EUI table implementation when the user interacts with the table (i.e. to update sorting) */
const onTableChange: OnTableChange = useCallback(({ page, sort }: OnTableChangeParams) => {
const { index, size } = page;
const { field, direction } = sort;
setPageIndex(index);
setPageSize(size);
setSortDirection(direction);
setSortField(field);
if (page != null) {
const { index, size } = page;
setPageIndex(index);
setPageSize(size);
}
if (sort != null) {
const { field, direction } = sort;
setSortDirection(direction);
setSortField(field);
}
}, []);
/** Invoked when the user toggles the option to only view favorite timelines */
@ -414,6 +419,7 @@ export const StatefulOpenTimelineComponent = React.memo<OpenTimelineOwnProps>(
selectedItems={selectedItems}
sortDirection={sortDirection}
sortField={sortField}
tabName={tabName}
templateTimelineFilter={templateTimelineFilter}
timelineType={timelineType}
timelineStatus={timelineStatus}

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import type { EuiBasicTable } from '@elastic/eui';
import React, { useCallback, useMemo, useRef } from 'react';
import type { EuiBasicTable } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import styled from 'styled-components';
@ -29,7 +29,8 @@ import { SearchRow } from './search_row';
import { TimelinesTable } from './timelines_table';
import * as i18n from './translations';
import { OPEN_TIMELINE_CLASS_NAME } from './helpers';
import type { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types';
import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from './types';
import { NoteManagementPage } from '../../../notes';
const QueryText = styled.span`
white-space: normal;
@ -63,13 +64,13 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
sortDirection,
setImportDataModalToggle,
sortField,
tabName,
timelineType = TimelineType.default,
timelineStatus,
timelineFilter,
templateTimelineFilter,
totalSearchResultsCount,
}) => {
const tableRef = useRef<EuiBasicTable<OpenTimelineResult>>();
const {
actionItem,
enableExportTimelineDownloader,
@ -78,7 +79,7 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
onOpenDeleteTimelineModal,
onCompleteEditTimelineAction,
} = useEditTimelineActions();
const tableRef = useRef<EuiBasicTable<OpenTimelineResult> | null>(null);
const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges();
const { getBatchItemsPopoverContent } = useEditTimelineBatchActions({
deleteTimelines: kibanaSecuritySolutionsPrivileges.crud ? deleteTimelines : undefined,
@ -227,84 +228,92 @@ export const OpenTimeline = React.memo<OpenTimelineProps>(
<div data-test-subj="timelines-page-container" className={OPEN_TIMELINE_CLASS_NAME}>
{!!timelineFilter && timelineFilter}
<SearchRow
data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
timelineType={timelineType}
>
{SearchRowContent}
</SearchRow>
{tabName !== 'notes' ? (
<>
<SearchRow
data-test-subj="search-row"
favoriteCount={favoriteCount}
onlyFavorites={onlyFavorites}
onQueryChange={onQueryChange}
onToggleOnlyFavorites={onToggleOnlyFavorites}
query={query}
timelineType={timelineType}
>
{SearchRowContent}
</SearchRow>
<UtilityBar border>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText data-test-subj="query-message">
<>
{i18n.SHOWING}{' '}
{timelineType === TimelineType.template ? nTemplates : nTimelines}
</>
</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
{timelineStatus !== TimelineStatus.immutable && (
<>
<UtilityBarText data-test-subj="selected-count">
{timelineType === TimelineType.template
? i18n.SELECTED_TEMPLATES(selectedItems.length)
: i18n.SELECTED_TIMELINES(selectedItems.length)}
<UtilityBar border>
<UtilityBarSection>
<UtilityBarGroup>
<UtilityBarText data-test-subj="query-message">
<>
{i18n.SHOWING}{' '}
{timelineType === TimelineType.template ? nTemplates : nTimelines}
</>
</UtilityBarText>
</UtilityBarGroup>
<UtilityBarGroup>
{timelineStatus !== TimelineStatus.immutable && (
<>
<UtilityBarText data-test-subj="selected-count">
{timelineType === TimelineType.template
? i18n.SELECTED_TEMPLATES(selectedItems.length)
: i18n.SELECTED_TIMELINES(selectedItems.length)}
</UtilityBarText>
<UtilityBarAction
dataTestSubj="batchActions"
iconSide="right"
iconType="arrowDown"
popoverContent={getBatchItemsPopoverContent}
data-test-subj="utility-bar-action"
>
<span data-test-subj="utility-bar-action-button">
{i18n.BATCH_ACTIONS}
</span>
</UtilityBarAction>
</>
)}
<UtilityBarAction
dataTestSubj="batchActions"
dataTestSubj="refreshButton"
iconSide="right"
iconType="arrowDown"
popoverContent={getBatchItemsPopoverContent}
data-test-subj="utility-bar-action"
iconType="refresh"
onClick={onRefreshBtnClick}
>
<span data-test-subj="utility-bar-action-button">{i18n.BATCH_ACTIONS}</span>
{i18n.REFRESH}
</UtilityBarAction>
</>
)}
<UtilityBarAction
dataTestSubj="refreshButton"
iconSide="right"
iconType="refresh"
onClick={onRefreshBtnClick}
>
{i18n.REFRESH}
</UtilityBarAction>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
</UtilityBarGroup>
</UtilityBarSection>
</UtilityBar>
<TimelinesTable
actionTimelineToShow={actionTimelineToShow}
data-test-subj="timelines-table"
deleteTimelines={deleteTimelines}
defaultPageSize={defaultPageSize}
loading={isLoading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
enableExportTimelineDownloader={enableExportTimelineDownloader}
onCreateRule={onCreateRule}
onCreateRuleFromEql={onCreateRuleFromEql}
onOpenDeleteTimelineModal={onOpenDeleteTimelineModal}
onOpenTimeline={onOpenTimeline}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
searchResults={searchResults}
showExtendedColumns={true}
sortDirection={sortDirection}
sortField={sortField}
timelineType={timelineType}
tableRef={tableRef}
totalSearchResultsCount={totalSearchResultsCount}
/>
<TimelinesTable
actionTimelineToShow={actionTimelineToShow}
data-test-subj="timelines-table"
deleteTimelines={deleteTimelines}
defaultPageSize={defaultPageSize}
loading={isLoading}
itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap}
enableExportTimelineDownloader={enableExportTimelineDownloader}
onCreateRule={onCreateRule}
onCreateRuleFromEql={onCreateRuleFromEql}
onOpenDeleteTimelineModal={onOpenDeleteTimelineModal}
onOpenTimeline={onOpenTimeline}
onSelectionChange={onSelectionChange}
onTableChange={onTableChange}
onToggleShowNotes={onToggleShowNotes}
pageIndex={pageIndex}
pageSize={pageSize}
searchResults={searchResults}
showExtendedColumns={true}
sortDirection={sortDirection}
sortField={sortField}
timelineType={timelineType}
totalSearchResultsCount={totalSearchResultsCount}
tableRef={tableRef}
/>
</>
) : (
<NoteManagementPage />
)}
</div>
</>
);

View file

@ -6,10 +6,11 @@
*/
import { EuiModalBody, EuiModalHeader, EuiSpacer } from '@elastic/eui';
import React, { Fragment, memo, useMemo } from 'react';
import type { EuiBasicTable } from '@elastic/eui';
import React, { Fragment, memo, useMemo, useRef } from 'react';
import styled from 'styled-components';
import type { OpenTimelineProps, ActionTimelineToShow } from '../types';
import type { OpenTimelineProps, ActionTimelineToShow, OpenTimelineResult } from '../types';
import { SearchRow } from '../search_row';
import { TimelinesTable } from '../timelines_table';
import { TitleRow } from '../title_row';
@ -49,6 +50,8 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
title,
totalSearchResultsCount,
}) => {
const tableRef = useRef<EuiBasicTable<OpenTimelineResult> | null>(null);
const actionsToShow = useMemo(() => {
const actions: ActionTimelineToShow[] = ['createFrom', 'duplicate'];
@ -118,6 +121,7 @@ export const OpenTimelineModalBody = memo<OpenTimelineProps>(
sortField={sortField}
timelineType={timelineType}
totalSearchResultsCount={totalSearchResultsCount}
tableRef={tableRef}
/>
</>
</EuiModalBody>

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { ICON_TYPES, EuiTableActionsColumnType } from '@elastic/eui';
import type {
ActionTimelineToShow,
DeleteTimelines,
@ -13,10 +14,11 @@ import type {
OnOpenTimeline,
OpenTimelineResult,
OnOpenDeleteTimelineModal,
TimelineActionsOverflowColumns,
} from '../types';
import * as i18n from '../translations';
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
type Action = EuiTableActionsColumnType<object>['actions'][number];
/**
* Returns the action columns (e.g. delete, open duplicate timeline)
*/
@ -38,10 +40,10 @@ export const getActionsColumns = ({
onCreateRule?: OnCreateRuleFromTimeline;
onCreateRuleFromEql?: OnCreateRuleFromTimeline;
hasCrudAccess: boolean;
}): [TimelineActionsOverflowColumns] => {
}): Array<EuiTableActionsColumnType<object>> => {
const createTimelineFromTemplate = {
name: i18n.CREATE_TIMELINE_FROM_TEMPLATE,
icon: 'timeline',
icon: 'timeline' as typeof ICON_TYPES[number],
onClick: ({ savedObjectId }: OpenTimelineResult) => {
onOpenTimeline({
duplicate: true,
@ -56,11 +58,11 @@ export const getActionsColumns = ({
'data-test-subj': 'create-from-template',
available: (item: OpenTimelineResult) =>
item.timelineType === TimelineType.template && actionTimelineToShow.includes('createFrom'),
};
} as Action;
const createTemplateFromTimeline = {
name: i18n.CREATE_TEMPLATE_FROM_TIMELINE,
icon: 'visText',
icon: 'visText' as typeof ICON_TYPES[number],
onClick: ({ savedObjectId }: OpenTimelineResult) => {
onOpenTimeline({
duplicate: true,
@ -75,11 +77,11 @@ export const getActionsColumns = ({
'data-test-subj': 'create-template-from-timeline',
available: (item: OpenTimelineResult) =>
item.timelineType !== TimelineType.template && actionTimelineToShow.includes('createFrom'),
};
} as Action;
const openAsDuplicateColumn = {
name: i18n.OPEN_AS_DUPLICATE,
icon: 'copy',
icon: 'copy' as typeof ICON_TYPES[number],
onClick: ({ savedObjectId }: OpenTimelineResult) => {
onOpenTimeline({
duplicate: true,
@ -92,11 +94,11 @@ export const getActionsColumns = ({
'data-test-subj': 'open-duplicate',
available: (item: OpenTimelineResult) =>
item.timelineType !== TimelineType.template && actionTimelineToShow.includes('duplicate'),
};
} as Action;
const openAsDuplicateTemplateColumn = {
name: i18n.OPEN_AS_DUPLICATE_TEMPLATE,
icon: 'copy',
icon: 'copy' as typeof ICON_TYPES[number],
onClick: ({ savedObjectId }: OpenTimelineResult) => {
onOpenTimeline({
duplicate: true,
@ -109,11 +111,12 @@ export const getActionsColumns = ({
'data-test-subj': 'open-duplicate-template',
available: (item: OpenTimelineResult) =>
item.timelineType === TimelineType.template && actionTimelineToShow.includes('duplicate'),
};
} as Action;
const exportTimelineAction = {
name: i18n.EXPORT_SELECTED,
icon: 'exportAction',
icon: 'exportAction' as typeof ICON_TYPES[number],
type: 'icon',
onClick: (selectedTimeline: OpenTimelineResult) => {
if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline);
},
@ -123,11 +126,12 @@ export const getActionsColumns = ({
description: i18n.EXPORT_SELECTED,
'data-test-subj': 'export-timeline',
available: () => actionTimelineToShow.includes('export'),
};
} as Action;
const deleteTimelineColumn = {
name: i18n.DELETE_SELECTED,
icon: 'trash',
icon: 'trash' as typeof ICON_TYPES[number],
type: 'icon',
onClick: (selectedTimeline: OpenTimelineResult) => {
if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline);
},
@ -136,11 +140,12 @@ export const getActionsColumns = ({
description: i18n.DELETE_SELECTED,
'data-test-subj': 'delete-timeline',
available: () => actionTimelineToShow.includes('delete') && deleteTimelines != null,
};
} as Action;
const createRuleFromTimeline = {
name: i18n.CREATE_RULE_FROM_TIMELINE,
icon: 'indexEdit',
icon: 'indexEdit' as typeof ICON_TYPES[number],
type: 'icon',
onClick: (selectedTimeline: OpenTimelineResult) => {
if (onCreateRule != null && selectedTimeline.savedObjectId)
onCreateRule(selectedTimeline.savedObjectId);
@ -156,11 +161,12 @@ export const getActionsColumns = ({
onCreateRule != null &&
queryType != null &&
queryType.hasQuery,
};
} as Action;
const createRuleFromTimelineCorrelation = {
name: i18n.CREATE_RULE_FROM_TIMELINE_CORRELATION,
icon: 'indexEdit',
icon: 'indexEdit' as typeof ICON_TYPES[number],
type: 'icon',
onClick: (selectedTimeline: OpenTimelineResult) => {
if (onCreateRuleFromEql != null && selectedTimeline.savedObjectId)
onCreateRuleFromEql(selectedTimeline.savedObjectId);
@ -176,7 +182,7 @@ export const getActionsColumns = ({
onCreateRuleFromEql != null &&
queryType != null &&
queryType.hasEql,
};
} as Action;
return [
{
width: hasCrudAccess ? '80px' : '150px',

View file

@ -6,6 +6,7 @@
*/
import { EuiButtonIcon, EuiLink } from '@elastic/eui';
import type { EuiBasicTableColumn, EuiTableDataType } from '@elastic/eui';
import { omit } from 'lodash/fp';
import React from 'react';
import styled from 'styled-components';
@ -42,8 +43,9 @@ export const getCommonColumns = ({
onToggleShowNotes: OnToggleShowNotes;
itemIdToExpandedNotesRowMap: Record<string, JSX.Element>;
timelineType: TimelineType | null;
}) => [
}): Array<EuiBasicTableColumn<object>> => [
{
dataType: 'auto' as EuiTableDataType,
isExpander: true,
render: ({ notes, savedObjectId }: OpenTimelineResult) =>
notes != null && notes.length > 0 && savedObjectId != null ? (
@ -64,7 +66,7 @@ export const getCommonColumns = ({
width: ACTION_COLUMN_WIDTH,
},
{
dataType: 'string',
dataType: 'string' as EuiTableDataType,
field: 'title',
name: timelineType === TimelineType.default ? i18n.TIMELINE_NAME : i18n.TIMELINE_TEMPLATE_NAME,
render: (title: string, timelineResult: OpenTimelineResult) =>
@ -92,7 +94,7 @@ export const getCommonColumns = ({
sortable: false,
},
{
dataType: 'string',
dataType: 'string' as EuiTableDataType,
field: 'description',
name: i18n.DESCRIPTION,
render: (description: string) => (
@ -103,7 +105,7 @@ export const getCommonColumns = ({
sortable: false,
},
{
dataType: 'date',
dataType: 'date' as EuiTableDataType,
field: 'updated',
name: i18n.LAST_MODIFIED,
render: (date: number, timelineResult: OpenTimelineResult) => (

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import type { EuiTableDataType } from '@elastic/eui';
import { defaultToEmptyTag } from '../../../../common/components/empty_value';
import * as i18n from '../translations';
@ -21,7 +21,7 @@ export const getExtendedColumns = (showExtendedColumns: boolean) => {
return [
{
dataType: 'string',
dataType: 'string' as EuiTableDataType,
field: 'updatedBy',
name: i18n.MODIFIED_BY,
render: (updatedBy: OpenTimelineResult['updatedBy']) => (

View file

@ -6,6 +6,7 @@
*/
import { EuiIcon, EuiToolTip } from '@elastic/eui';
import type { EuiTableFieldDataColumnType, HorizontalAlignment } from '@elastic/eui';
import React from 'react';
import { ACTION_COLUMN_WIDTH } from './common_styles';
@ -22,10 +23,10 @@ export const getIconHeaderColumns = ({
timelineType,
}: {
timelineType: TimelineTypeLiteralWithNull;
}) => {
}): Array<EuiTableFieldDataColumnType<object>> => {
const columns = {
note: {
align: 'center',
align: 'center' as HorizontalAlignment,
field: 'eventIdToNoteIds',
name: (
<EuiToolTip content={i18n.NOTES}>
@ -40,7 +41,7 @@ export const getIconHeaderColumns = ({
width: ACTION_COLUMN_WIDTH,
},
pinnedEvent: {
align: 'center',
align: 'center' as HorizontalAlignment,
field: 'pinnedEventIds',
name: (
<EuiToolTip content={i18n.PINNED_EVENTS}>
@ -57,7 +58,7 @@ export const getIconHeaderColumns = ({
width: ACTION_COLUMN_WIDTH,
},
favorite: {
align: 'center',
align: 'center' as HorizontalAlignment,
field: 'favorite',
name: (
<EuiToolTip content={i18n.FAVORITES}>

View file

@ -5,9 +5,10 @@
* 2.0.
*/
import { EuiBasicTable as _EuiBasicTable } from '@elastic/eui';
import { EuiBasicTable } from '@elastic/eui';
import type { EuiBasicTableColumn } from '@elastic/eui';
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { css } from '@emotion/react';
import * as i18n from '../translations';
import type {
@ -29,19 +30,6 @@ import { getIconHeaderColumns } from './icon_header_columns';
import type { TimelineTypeLiteralWithNull } from '../../../../../common/api/timeline';
import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline';
import { useUserPrivileges } from '../../../../common/components/user_privileges';
// there are a number of type mismatches across this file
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
const BasicTable = styled(EuiBasicTable)`
.euiTableCellContent {
animation: none; /* Prevents applying max-height from animation */
}
.euiTableRow-isExpandedRow .euiTableCellContent__text {
width: 100%; /* Fixes collapsing nested flex content in IE11 */
}
`;
BasicTable.displayName = 'BasicTable';
/**
* Returns the column definitions (passed as the `columns` prop to
@ -77,7 +65,7 @@ export const getTimelinesTableColumns = ({
showExtendedColumns: boolean;
timelineType: TimelineTypeLiteralWithNull;
hasCrudAccess: boolean;
}) => {
}): Array<EuiBasicTableColumn<object>> => {
return [
...getCommonColumns({
itemIdToExpandedNotesRowMap,
@ -123,8 +111,7 @@ export interface TimelinesTableProps {
sortDirection: 'asc' | 'desc';
sortField: string;
timelineType: TimelineTypeLiteralWithNull;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tableRef?: React.MutableRefObject<_EuiBasicTable<any> | undefined>;
tableRef: React.MutableRefObject<EuiBasicTable<OpenTimelineResult> | null>;
totalSearchResultsCount: number;
}
@ -157,33 +144,39 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
timelineType,
totalSearchResultsCount,
}) => {
const pagination = {
showPerPageOptions: showExtendedColumns,
pageIndex,
pageSize,
pageSizeOptions: [
Math.floor(Math.max(defaultPageSize, 1) / 2),
defaultPageSize,
defaultPageSize * 2,
],
totalItemCount: totalSearchResultsCount,
};
const pagination = useMemo(() => {
return {
showPerPageOptions: showExtendedColumns,
pageIndex,
pageSize,
pageSizeOptions: [
Math.floor(Math.max(defaultPageSize, 1) / 2),
defaultPageSize,
defaultPageSize * 2,
],
totalItemCount: totalSearchResultsCount,
};
}, [defaultPageSize, pageIndex, pageSize, showExtendedColumns, totalSearchResultsCount]);
const sorting = {
sort: {
field: sortField as keyof OpenTimelineResult,
direction: sortDirection,
},
};
const sorting = useMemo(() => {
return {
sort: {
field: sortField as keyof OpenTimelineResult,
direction: sortDirection,
},
};
}, [sortField, sortDirection]);
const selection = {
selectable: (timelineResult: OpenTimelineResult) =>
timelineResult.savedObjectId != null && timelineResult.status !== TimelineStatus.immutable,
selectableMessage: (selectable: boolean) =>
!selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined,
onSelectionChange,
};
const basicTableProps = tableRef != null ? { ref: tableRef } : {};
const selection = useMemo(() => {
return {
selectable: (timelineResult: OpenTimelineResult) =>
timelineResult.savedObjectId != null &&
timelineResult.status !== TimelineStatus.immutable,
selectableMessage: (selectable: boolean) =>
!selectable ? i18n.MISSING_SAVED_OBJECT_ID : '',
onSelectionChange,
};
}, [onSelectionChange]);
const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges();
const columns = useMemo(
() =>
@ -227,7 +220,7 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
: i18n.ZERO_TIMELINES_MATCH;
return (
<BasicTable
<EuiBasicTable
columns={columns}
data-test-subj="timelines-table"
itemId="savedObjectId"
@ -239,7 +232,16 @@ export const TimelinesTable = React.memo<TimelinesTableProps>(
pagination={pagination}
selection={actionTimelineToShow.includes('selectable') ? selection : undefined}
sorting={sorting}
{...basicTableProps}
css={css`
.euiTableCellContent {
animation: none; /* Prevents applying max-height from animation */
}
.euiTableRow-isExpandedRow .euiTableCellContent__text {
width: 100%; /* Fixes collapsing nested flex content in IE11 */
}
`}
ref={tableRef}
/>
);
}

View file

@ -33,4 +33,5 @@ export const getMockTimelinesTableProps = (
sortField: DEFAULT_SORT_FIELD,
timelineType: TimelineType.default,
totalSearchResultsCount: mockOpenTimelineResults.length,
tableRef: { current: null },
});

View file

@ -6,6 +6,7 @@
*/
import type React from 'react';
import type { IconType } from '@elastic/eui';
import type { TimelineModel } from '../../store/model';
import type {
RowRendererId,
@ -39,11 +40,11 @@ export interface TimelineActionsOverflowColumns {
width: string;
actions: Array<{
name: string;
icon?: string;
icon: IconType;
onClick?: (timeline: OpenTimelineResult) => void;
description: string;
render?: (timeline: OpenTimelineResult) => JSX.Element;
} | null>;
}>;
}
/** The results of the query run by the OpenTimeline component */
@ -117,11 +118,11 @@ export type OnToggleShowNotes = (itemIdToExpandedNotesRowMap: Record<string, JSX
/** Parameters to the OnTableChange callback */
export interface OnTableChangeParams {
page: {
page?: {
index: number;
size: number;
};
sort: {
sort?: {
field: string;
direction: 'asc' | 'desc';
};
@ -207,6 +208,7 @@ export interface OpenTimelineProps {
totalSearchResultsCount: number;
/** Hide action on timeline if needed it */
hideActions?: ActionTimelineToShow[];
tabName?: string;
}
export interface ResolveTimelineConfig {

View file

@ -10,9 +10,12 @@ import { fireEvent, render } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react-hooks';
import type { UseTimelineTypesArgs, UseTimelineTypesResult } from './use_timeline_types';
import { useTimelineTypes } from './use_timeline_types';
import { TestProviders } from '../../../common/mock';
jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useParams: jest.fn().mockReturnValue('default'),
useHistory: jest.fn().mockReturnValue([]),
};
@ -50,7 +53,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
expect(result.current).toEqual({
timelineType: 'default',
@ -66,7 +71,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(result.current.timelineTabs);
@ -84,7 +91,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(result.current.timelineTabs);
@ -110,7 +119,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(result.current.timelineTabs);
@ -138,7 +149,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(<>{result.current.timelineFilters}</>);
@ -156,7 +169,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(<>{result.current.timelineFilters}</>);
@ -182,7 +197,9 @@ describe('useTimelineTypes', () => {
const { result, waitForNextUpdate } = renderHook<
UseTimelineTypesArgs,
UseTimelineTypesResult
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }));
>(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 }), {
wrapper: TestProviders,
});
await waitForNextUpdate();
const { container } = render(<>{result.current.timelineFilters}</>);

View file

@ -18,6 +18,7 @@ import * as i18n from './translations';
import type { TimelineTab } from './types';
import { TimelineTabsStyle } from './types';
import { useKibana } from '../../../common/lib/kibana';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
export interface UseTimelineTypesArgs {
defaultTimelineCount?: number | null;
templateTimelineCount?: number | null;
@ -42,8 +43,18 @@ export const useTimelineTypes = ({
: TimelineType.default
);
const timelineUrl = formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch));
const templateUrl = formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch));
const notesEnabled = useIsExperimentalFeatureEnabled('securitySolutionNotesEnabled');
const timelineUrl = useMemo(() => {
return formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch));
}, [formatUrl, urlSearch]);
const templateUrl = useMemo(() => {
return formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch));
}, [formatUrl, urlSearch]);
const notesUrl = useMemo(() => {
return formatUrl(getTimelineTabsUrl('notes', urlSearch));
}, [formatUrl, urlSearch]);
const goToTimeline = useCallback(
(ev) => {
@ -60,6 +71,15 @@ export const useTimelineTypes = ({
},
[navigateToUrl, templateUrl]
);
const goToNotes = useCallback(
(ev) => {
ev.preventDefault();
navigateToUrl(notesUrl);
},
[navigateToUrl, notesUrl]
);
const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback(
(timelineTabsStyle: TimelineTabsStyle) => [
{
@ -113,6 +133,17 @@ export const useTimelineTypes = ({
{tab.name}
</EuiTab>
))}
{notesEnabled && (
<EuiTab
data-test-subj="timeline-notes"
isSelected={tabName === 'notes'}
key="timeline-notes"
href={notesUrl}
onClick={goToNotes}
>
{'Notes'}
</EuiTab>
)}
</EuiTabs>
<EuiSpacer size="m" />
</>

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { SecurityPageName, SERVER_APP_ID, TIMELINES_PATH } from '../../common/constants';
import { TIMELINES } from '../app/translations';
import { TIMELINES, NOTES } from '../app/translations';
import type { LinkItem } from '../common/links/types';
export const links: LinkItem = {
@ -30,5 +30,16 @@ export const links: LinkItem = {
path: `${TIMELINES_PATH}/template`,
sideNavDisabled: true,
},
{
id: SecurityPageName.notesManagement,
title: NOTES,
description: i18n.translate('xpack.securitySolution.appLinks.notesManagementDescription', {
defaultMessage: 'Visualize and delete notes.',
}),
path: `${TIMELINES_PATH}/notes`,
skipUrlState: true,
hideTimeline: true,
experimentalKey: 'securitySolutionNotesEnabled',
},
],
};

View file

@ -17,7 +17,7 @@ import { appendSearch } from '../../common/components/link_to/helpers';
import { TIMELINES_PATH } from '../../../common/constants';
const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template})`;
const timelinesPagePath = `${TIMELINES_PATH}/:tabName(${TimelineType.default}|${TimelineType.template}|notes)`;
const timelinesDefaultPath = `${TIMELINES_PATH}/${TimelineType.default}`;
export const Timelines = React.memo(() => (

View file

@ -41,7 +41,7 @@ export const TimelinesPage = React.memo(() => {
{indicesExist ? (
<SecuritySolutionPageWrapper>
<HeaderPage title={i18n.PAGE_TITLE}>
{capabilitiesCanUserCRUD && (
{capabilitiesCanUserCRUD && tabName !== 'notes' ? (
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem>
<EuiButton
@ -56,7 +56,7 @@ export const TimelinesPage = React.memo(() => {
<NewTimelineButton type={timelineType} />
</EuiFlexItem>
</EuiFlexGroup>
)}
) : null}
</HeaderPage>
<StatefulOpenTimeline
@ -66,6 +66,7 @@ export const TimelinesPage = React.memo(() => {
setImportDataModalToggle={setImportDataModal}
title={i18n.ALL_TIMELINES_PANEL_TITLE}
data-test-subj="stateful-open-timeline"
tabName={tabName}
/>
</SecuritySolutionPageWrapper>
) : (