mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Security Solution][Timeline] Notes management table (#187214)
This commit is contained in:
parent
0da7bbe318
commit
1fa094b7ac
30 changed files with 1414 additions and 370 deletions
|
@ -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) =>
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
|
@ -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';
|
|
@ -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',
|
||||
});
|
|
@ -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';
|
9
x-pack/plugins/security_solution/public/notes/index.ts
Normal file
9
x-pack/plugins/security_solution/public/notes/index.ts
Normal 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';
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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']) => (
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -33,4 +33,5 @@ export const getMockTimelinesTableProps = (
|
|||
sortField: DEFAULT_SORT_FIELD,
|
||||
timelineType: TimelineType.default,
|
||||
totalSearchResultsCount: mockOpenTimelineResults.length,
|
||||
tableRef: { current: null },
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}</>);
|
||||
|
|
|
@ -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" />
|
||||
</>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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(() => (
|
||||
|
|
|
@ -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>
|
||||
) : (
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue