From 340da968b21c5cbab4ea5f024483edaebf09a2c3 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 23 May 2024 14:53:39 +0000 Subject: [PATCH 01/20] WIP api hack and slash attempt --- .../common/api/timeline/model/api.ts | 10 +++ .../persist_note/persist_note_route.ts | 20 ++++- .../types/timeline/note/saved_object.ts | 22 +++--- .../components/header_actions/actions.tsx | 19 ++++- .../header_actions/add_note_icon_item.tsx | 8 +- .../public/common/store/app/actions.ts | 12 +++ .../public/common/store/app/model.ts | 4 + .../public/common/store/app/reducer.ts | 25 ++++++- .../public/common/store/app/selectors.ts | 20 +++++ .../components/alerts_table/index.tsx | 12 +++ .../use_actions_column.tsx | 2 +- .../public/timelines/containers/notes/api.ts | 8 ++ .../create_timeline_middlewares.ts | 2 + .../store/middlewares/unassociated_notes.ts | 39 ++++++++++ .../server/lib/timeline/routes/index.ts | 4 +- .../lib/timeline/routes/notes/get_notes.ts | 73 +++++++++++++++++++ .../server/lib/timeline/routes/notes/index.ts | 1 + .../lib/timeline/routes/notes/persist_note.ts | 14 ++-- .../saved_object/notes/saved_object.ts | 28 ++++--- .../server/lib/timeline/utils/common.ts | 2 + .../alerts_table/alerts_table_state.tsx | 2 +- .../alerts_table/hooks/use_fetch_alerts.tsx | 6 +- 22 files changed, 289 insertions(+), 44 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index 5230552f3e836..3e69bd14b646c 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -45,6 +45,16 @@ export type BareNote = runtimeTypes.TypeOf; */ export type BareNoteWithoutExternalRefs = Omit; +export const BareNoteWithoutExternalRefsSchema = runtimeTypes.partial({ + timelineId: unionWithNullType(runtimeTypes.string), + eventId: unionWithNullType(runtimeTypes.string), + note: unionWithNullType(runtimeTypes.string), + created: unionWithNullType(runtimeTypes.number), + createdBy: unionWithNullType(runtimeTypes.string), + updated: unionWithNullType(runtimeTypes.number), + updatedBy: unionWithNullType(runtimeTypes.string), +}); + export const NoteRuntimeType = runtimeTypes.intersection([ BareNoteSchema, runtimeTypes.type({ diff --git a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts index b588e6829aaba..a4426467637fa 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts @@ -7,9 +7,9 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../../../utility_types'; -import { BareNoteSchema } from '../model/api'; +import { BareNoteSchema, BareNoteWithoutExternalRefsSchema } from '../model/api'; -export const persistNoteSchema = runtimeTypes.intersection([ +export const persistNoteWithRefSchema = runtimeTypes.intersection([ runtimeTypes.type({ note: BareNoteSchema, }), @@ -19,3 +19,19 @@ export const persistNoteSchema = runtimeTypes.intersection([ version: unionWithNullType(runtimeTypes.string), }), ]); + +export const persistNoteWithoutRefSchema = runtimeTypes.intersection([ + runtimeTypes.type({ + note: BareNoteWithoutExternalRefsSchema, + }), + runtimeTypes.partial({ + overrideOwner: unionWithNullType(runtimeTypes.boolean), + noteId: unionWithNullType(runtimeTypes.string), + version: unionWithNullType(runtimeTypes.string), + }), +]); + +export const persistNoteSchema = runtimeTypes.union([ + persistNoteWithRefSchema, + persistNoteWithoutRefSchema, +]); diff --git a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts index 1fad511424eda..1de998559e76f 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts @@ -14,19 +14,15 @@ import { unionWithNullType } from '../../../utility_types'; /* * Note Types */ -const SavedNoteRuntimeType = runtimeTypes.intersection([ - runtimeTypes.type({ - timelineId: runtimeTypes.string, - }), - runtimeTypes.partial({ - eventId: unionWithNullType(runtimeTypes.string), - note: unionWithNullType(runtimeTypes.string), - created: unionWithNullType(runtimeTypes.number), - createdBy: unionWithNullType(runtimeTypes.string), - updated: unionWithNullType(runtimeTypes.number), - updatedBy: unionWithNullType(runtimeTypes.string), - }), -]); +const SavedNoteRuntimeType = runtimeTypes.partial({ + timelineId: unionWithNullType(runtimeTypes.string), + eventId: unionWithNullType(runtimeTypes.string), + note: unionWithNullType(runtimeTypes.string), + created: unionWithNullType(runtimeTypes.number), + createdBy: unionWithNullType(runtimeTypes.string), + updated: unionWithNullType(runtimeTypes.number), + updatedBy: unionWithNullType(runtimeTypes.string), +}); /** * Note Saved object type with metadata diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index 35028da2169a2..d4f12e232a8fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; @@ -19,6 +19,7 @@ import { import { getScopedActions, isTimelineScope } from '../../../helpers'; import { useIsInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; import { timelineActions, timelineSelectors } from '../../../timelines/store'; +import { appSelectors } from '../../store'; import type { ActionProps, OnPinEvent } from '../../../../common/types'; import { TimelineId } from '../../../../common/types'; import { AddEventNoteAction } from './add_note_icon_item'; @@ -119,7 +120,10 @@ const ActionsComponent: React.FC = ({ !(ecsData.event?.kind?.includes('event') && ecsData.agent?.type?.includes('endpoint')) ); }, [ecsData, eventType]); - + const nonAssociatedNotes = useSelector((state) => + appSelectors.nonAssociatedNotesByIdSelector(state, eventId) + ); + console.log('nonAssociatedNotes', nonAssociatedNotes); const isDisabled = !useIsInvestigateInResolverActionEnabled(ecsData); const { setGlobalFullScreen } = useGlobalFullScreen(); const { setTimelineFullScreen } = useTimelineFullScreen(); @@ -283,6 +287,17 @@ const ActionsComponent: React.FC = ({ /> )} + {timelineId === 'alerts-page' && ( + {}} + timelineType={timelineType} + eventId={eventId} + eventCount={nonAssociatedNotes.length} + /> + )} {!isEventViewer && toggleShowNotes && ( <> void; eventId?: string; + eventCount?: number; } const AddEventNoteActionComponent: React.FC = ({ @@ -26,8 +27,11 @@ const AddEventNoteActionComponent: React.FC = ({ timelineType, toggleShowNotes, eventId, + eventCount, }) => { const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); + const toolTip = + timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP; return ( @@ -38,9 +42,7 @@ const AddEventNoteActionComponent: React.FC = ({ showNotes={showNotes} timelineType={timelineType} toggleShowNotes={toggleShowNotes} - toolTip={ - timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP - } + toolTip={eventCount ?? toolTip} eventId={eventId} /> diff --git a/x-pack/plugins/security_solution/public/common/store/app/actions.ts b/x-pack/plugins/security_solution/public/common/store/app/actions.ts index 2ddf3b34475a7..13872b2c91c23 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/actions.ts @@ -23,6 +23,18 @@ export const addError = actionCreator<{ id: string; title: string; message: stri export const removeError = actionCreator<{ id: string }>('REMOVE_ERRORS'); +export const setEventIdsToFetchNotesFor = actionCreator<{ eventIds: string[] }>( + 'SET_EVENT_IDS_TO_FETCH_NOTES_FOR' +); + +export const setNonTimelineEventNotesLoading = actionCreator<{ isLoading: boolean }>( + 'SET_NON_TIMELINE_EVENT_NOTES_LOADING' +); + +export const serverReturnedNonAssociatedNotes = actionCreator<{ notes: Note[] }>( + 'SERVER_RETURNED_NON_ASSOCIATED_NOTES' +); + export const addErrorHash = actionCreator<{ id: string; hash: string; diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 69a20da4598b1..e3d6c02bf65ce 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -28,4 +28,8 @@ export interface AppModel { notesById: NotesById; errors: ErrorState; enableExperimental: ExperimentalFeatures; + eventIdsToFetch: string[]; + nonTimelineEventNotesLoading: boolean; + nonTimelineEventNotesError: string | null; + nonAssociatedNotes: Note[]; } diff --git a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts index 4928fc9e6e150..57248c7deb247 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts @@ -9,7 +9,16 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers'; import type { Note } from '../../lib/note'; -import { addError, addErrorHash, addNotes, removeError, updateNote, deleteNote } from './actions'; +import { + addError, + addErrorHash, + addNotes, + removeError, + updateNote, + deleteNote, + setEventIdsToFetchNotesFor, + serverReturnedNonAssociatedNotes, +} from './actions'; import type { AppModel, NotesById } from './model'; import { allowedExperimentalValues } from '../../../../common/experimental_features'; @@ -19,6 +28,8 @@ export const initialAppState: AppState = { notesById: {}, errors: [], enableExperimental: { ...allowedExperimentalValues }, + eventIdsToFetch: [], + nonAssociatedNotes: [], }; interface UpdateNotesByIdParams { @@ -78,4 +89,16 @@ export const appReducer = reducerWithInitialState(initialAppState) ], }; }) + .case(setEventIdsToFetchNotesFor, (state, { eventIds }) => { + return { + ...state, + eventIdsToFetch: eventIds, + }; + }) + .case(serverReturnedNonAssociatedNotes, (state, { notes }) => { + return { + ...state, + nonAssociatedNotes: notes, + }; + }) .build(); diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 47df2527f9edd..98504ec9af1ce 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -48,4 +48,24 @@ export const notesByIdsSelector = () => export const selectNotesAsCommentsListSelector = () => createSelector(selectNotesById, getNotesAsCommentsList); +export const selectEventIdsToFetchNotes = (state: State): string[] => state.app.eventIdsToFetch; + +export const selectNonAssociatedNotes = (state: State): Note[] => state.app.nonAssociatedNotes; + +export const nonAssociatedNotesSelector = createSelector( + selectNonAssociatedNotes, + (notes) => notes +); + +export const nonAssociatedNotesByIdSelector = createSelector( + nonAssociatedNotesSelector, + (state: State, eventId: string) => eventId, + (notes, eventId) => notes.filter((note) => note.eventId === eventId) +); + +export const selectEventIdsToFetchNotesSelector = createSelector( + selectEventIdsToFetchNotes, + (ids) => ids +); + export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 599c32414d966..52f05c9d9aa04 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -25,6 +25,7 @@ import { import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useLicense } from '../../../common/hooks/use_license'; import { VIEW_SELECTION } from '../../../../common/constants'; +import { appActions } from '../../../common/store/app'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; @@ -265,6 +266,15 @@ export const AlertsTableComponent: FC = ({ }; }, []); + const onLoaded = useCallback( + (alerts) => { + const alertIds = alerts.map((alert) => alert._id); + console.log('Loaded alerts:', alertIds); + dispatch(appActions.setEventIdsToFetchNotesFor({ eventIds: alertIds })); + }, + [dispatch] + ); + const alertStateProps: AlertsTableStateProps = useMemo( () => ({ alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, @@ -280,6 +290,7 @@ export const AlertsTableComponent: FC = ({ browserFields: finalBrowserFields, onUpdate: onAlertTableUpdate, cellContext, + onLoaded, runtimeMappings, toolbarVisibility: { showColumnSelector: !isEventRenderedView, @@ -300,6 +311,7 @@ export const AlertsTableComponent: FC = ({ runtimeMappings, isEventRenderedView, cellContext, + onLoaded, ] ); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx index 840cc0efb5529..fbb89c511109d 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx @@ -24,7 +24,7 @@ export const getUseActionColumnHook = () => { const license = useLicense(); const isEnterprisePlus = license.isEnterprise(); - const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 3 : isEnterprisePlus ? 5 : 4; + const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 4 : isEnterprisePlus ? 6 : 5; const eventContext = useContext(StatefulEventContext); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts index 25cb18f574260..54d10cf1507e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts @@ -34,3 +34,11 @@ export const persistNote = async ({ }); return response; }; + +export const getNotesByIds = async (documentIds: string[]) => { + const response = await KibanaServices.get().http.get(NOTE_URL, { + query: { alertIds: documentIds }, + version: '2023-10-31', + }); + return response; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts index d473f509c688c..994c9bc87eb47 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts @@ -10,6 +10,7 @@ import type { CoreStart } from '@kbn/core/public'; import { timelineChangedMiddleware } from './timeline_changed'; import { favoriteTimelineMiddleware } from './timeline_favorite'; import { addNoteToTimelineMiddleware } from './timeline_note'; +import { displayUnassociatedNotesMiddleware } from './unassociated_notes'; import { addPinnedEventToTimelineMiddleware } from './timeline_pinned_event'; import { saveTimelineMiddleware } from './timeline_save'; @@ -20,5 +21,6 @@ export function createTimelineMiddlewares(kibana: CoreStart) { addNoteToTimelineMiddleware(kibana), addPinnedEventToTimelineMiddleware(kibana), saveTimelineMiddleware(kibana), + displayUnassociatedNotesMiddleware(kibana), ]; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts new file mode 100644 index 0000000000000..daa84dfbf343e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts @@ -0,0 +1,39 @@ +/* + * 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 type { Action, Middleware } from 'redux'; +import type { CoreStart } from '@kbn/core/public'; + +import {appActions } from '../../../common/store/app'; +import type { State } from '../../../common/store/types'; +import { setEventIdsToFetchNotesFor } from '../../../common/store/app/actions'; +import type { Note } from '../../../common/lib/note'; +import { getNotesByIds } from '../../containers/notes/api'; + +export const displayUnassociatedNotesMiddleware: (kibana: CoreStart) => Middleware<{}, State> = + (kibana: CoreStart) => (store) => (next) => async (action: Action) => { + // perform the action + const ret = next(action); + + if (action.type === setEventIdsToFetchNotesFor.type) { + const eventIds = action.payload.eventIds; + console.log('hello from middleware', eventIds); + store.dispatch(appActions.setNonTimelineEventNotesLoading({ isLoading: true })); + + try { + const response = await getNotesByIds(eventIds); + const notes: Note[] = response.notes; + console.log('notes', notes); + store.dispatch(appActions.serverReturnedNonAssociatedNotes({ notes })); + } catch (error) { + console.error('Error fetching notes:', error); + } + store.dispatch(appActions.setNonTimelineEventNotesLoading({ isLoading: false })); + } + + return ret; + }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts index 33a0b6f09d3e6..1a99a0cdac240 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts @@ -24,7 +24,7 @@ import { getDraftTimelinesRoute } from './draft_timelines/get_draft_timelines'; import { cleanDraftTimelinesRoute } from './draft_timelines/clean_draft_timelines'; import { installPrepackedTimelinesRoute } from './prepackaged_timelines/install_prepackaged_timelines'; -import { persistNoteRoute, deleteNoteRoute } from './notes'; +import { persistNoteRoute, deleteNoteRoute, getNotesByDocumentIdsRoute } from './notes'; import { persistPinnedEventRoute } from './pinned_events'; @@ -51,5 +51,7 @@ export function registerTimelineRoutes( persistNoteRoute(router, config, security); deleteNoteRoute(router, config, security); + getNotesByDocumentIdsRoute(router, config, security); + persistPinnedEventRoute(router, config, security); } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts new file mode 100644 index 0000000000000..e0180e1fe5d2b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -0,0 +1,73 @@ +/* + * 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 { transformError } from '@kbn/securitysolution-es-utils'; +import type { SecuritySolutionPluginRouter } from '../../../../types'; +import { NOTE_URL } from '../../../../../common/constants'; + +import type { ConfigType } from '../../../..'; +import type { SetupPlugins } from '../../../../plugin'; + +import { buildSiemResponse } from '../../../detection_engine/routes/utils'; + +import { CustomHttpRequestError } from '../../../../utils/custom_http_request_error'; +import { buildFrameworkRequest, getNotesByDocumentIds, throwErrors, escapeHatch } from '../../utils/common'; +import { getAllSavedNote } from '../../saved_object/notes'; +import { noteSavedObjectType } from '../../saved_object_mappings/notes'; + +export const getNotesByDocumentIdsRoute = ( + router: SecuritySolutionPluginRouter, + _: ConfigType, + security: SetupPlugins['security'] +) => { + router.versioned + .get({ + path: NOTE_URL, + options: { + tags: ['access:securitySolution'], + }, + access: 'public', + }) + .addVersion( + { + validate: { + request: { query: escapeHatch }, + }, + version: '2023-10-31', + }, + async (context, request, response) => { + const customHttpRequestError = (message: string) => + new CustomHttpRequestError(message, 400); + try { + const frameworkRequest = await buildFrameworkRequest(context, security, request); + const alertIds = request.query?.alertIds ?? null; + console.log('alertIds:', alertIds); + // const pageSize = queryParams?.page_size ? parseInt(queryParams.page_size, 10) : null; + // const pageIndex = queryParams?.page_index ? parseInt(queryParams.page_index, 10) : null; + // const search = queryParams?.search ?? null; + // const sortField = queryParams?.sort_field ?? null; + // const sortOrder = queryParams?.sort_order ?? null; + const alertIdSearchString = alertIds?.join(' | '); + const options = { + type: noteSavedObjectType, + search: alertIdSearchString, + }; + const res = await getAllSavedNote(frameworkRequest, options); + + return response.ok({ body: res ?? {} }); + } catch (err) { + const error = transformError(err); + const siemResponse = buildSiemResponse(response); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts index 43d32c173ab1f..c8a0ee8f2c841 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts @@ -7,3 +7,4 @@ export { persistNoteRoute } from './persist_note'; export { deleteNoteRoute } from './delete_note'; +export { getNotesByDocumentIdsRoute } from './get_notes'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts index a6b502111f7cc..627b0d327a1f5 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts @@ -11,13 +11,16 @@ import type { SecuritySolutionPluginRouter } from '../../../../types'; import { NOTE_URL } from '../../../../../common/constants'; import type { SetupPlugins } from '../../../../plugin'; -import { buildRouteValidationWithExcess } from '../../../../utils/build_validation/route_validation'; +import { + // buildRouteValidationWithExcess, + buildRouteValidation, +} from '../../../../utils/build_validation/route_validation'; import type { ConfigType } from '../../../..'; import { buildSiemResponse } from '../../../detection_engine/routes/utils'; import { buildFrameworkRequest } from '../../utils/common'; -import { persistNoteSchema } from '../../../../../common/api/timeline'; +import { persistNoteSchema, persistNoteWithoutRefSchema } from '../../../../../common/api/timeline'; import { persistNote } from '../../saved_object/notes'; export const persistNoteRoute = ( @@ -36,7 +39,7 @@ export const persistNoteRoute = ( .addVersion( { validate: { - request: { body: buildRouteValidationWithExcess(persistNoteSchema) }, + request: { body: buildRouteValidation(persistNoteWithoutRefSchema) }, }, version: '2023-10-31', }, @@ -51,10 +54,7 @@ export const persistNoteRoute = ( const res = await persistNote({ request: frameworkRequest, noteId, - note: { - ...note, - timelineId: note.timelineId, - }, + note, overrideOwner: true, }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 18b6e19dfcb00..2d4e67b66e89f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -19,7 +19,7 @@ import { getUserDisplayName } from '@kbn/user-profile-components'; import { UNAUTHENTICATED_USER } from '../../../../../common/constants'; import type { Note, - BareNote, + // BareNote, BareNoteWithoutExternalRefs, ResponseNote, } from '../../../../../common/api/timeline'; @@ -83,7 +83,7 @@ export const persistNote = async ({ }: { request: FrameworkRequest; noteId: string | null; - note: BareNote; + note: BareNoteWithoutExternalRefs; overrideOwner?: boolean; }): Promise => { try { @@ -124,7 +124,7 @@ const createNote = async ({ }: { request: FrameworkRequest; noteId: string | null; - note: BareNote; + note: BareNoteWithoutExternalRefs; overrideOwner?: boolean; }) => { const savedObjectsClient = (await request.context.core).savedObjects.client; @@ -137,6 +137,8 @@ const createNote = async ({ data: noteWithCreator, }); + console.log('migratedAttributes:', migratedAttributes); + const noteAttributes: SavedObjectNoteWithoutExternalRefs = { eventId: migratedAttributes.eventId, note: migratedAttributes.note, @@ -174,7 +176,7 @@ const updateNote = async ({ }: { request: FrameworkRequest; noteId: string; - note: BareNote; + note: BareNoteWithoutExternalRefs; overrideOwner?: boolean; }) => { const savedObjectsClient = (await request.context.core).savedObjects.client; @@ -233,7 +235,10 @@ const getSavedNote = async (request: FrameworkRequest, NoteId: string) => { return convertSavedObjectToSavedNote(populatedNote); }; -const getAllSavedNote = async (request: FrameworkRequest, options: SavedObjectsFindOptions) => { +export const getAllSavedNote = async ( + request: FrameworkRequest, + options: SavedObjectsFindOptions +) => { const savedObjectsClient = (await request.context.core).savedObjects.client; const savedObjects = await savedObjectsClient.find(options); @@ -247,14 +252,15 @@ const getAllSavedNote = async (request: FrameworkRequest, options: SavedObjectsF }; }; -export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => - pipe( +export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => { + console.log(savedObject, 'savedObject'); + return pipe( SavedObjectNoteRuntimeType.decode(savedObject), map((savedNote) => { return { noteId: savedNote.id, version: savedNote.version, - timelineId: savedNote.attributes.timelineId, + timelineId: savedNote.attributes.timelineId ?? '', eventId: savedNote.attributes.eventId, note: savedNote.attributes.note, created: savedNote.attributes.created, @@ -264,13 +270,15 @@ export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => }; }), fold((errors) => { - throw new Error(failure(errors).join('\n')); + console.log('would throw here'); + // throw new Error(failure(errors).join('\n')); }, identity) ); +}; export const pickSavedNote = ( noteId: string | null, - savedNote: BareNote, + savedNote: BareNoteWithoutExternalRefs, userInfo: AuthenticatedUser | null ) => { if (noteId == null) { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts index 97b6ec743d109..7aa2cacfa4ede 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts @@ -39,6 +39,8 @@ export const buildFrameworkRequest = async ( export const escapeHatch = schema.object({}, { unknowns: 'allow' }); +export const getNotesByDocumentIds = schema.object({ alertIds: schema.arrayOf(schema.string()) }); + type ErrorFactory = (message: string) => Error; export const throwErrors = (createError: ErrorFactory) => (errors: rt.Errors) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index 70aaf7a304453..0559d13ffc781 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -78,7 +78,7 @@ export type AlertsTableStateProps = { pageSize?: number; browserFields?: BrowserFields; onUpdate?: (args: TableUpdateHandlerArgs) => void; - onLoaded?: () => void; + onLoaded?: (alerts: Alerts) => void; runtimeMappings?: MappingRuntimeFields; showAlertStatusWithFlapping?: boolean; toolbarVisibility?: EuiDataGridToolBarVisibilityOptions; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx index 5a0a5efb18c32..c47c2d3207248 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx @@ -36,7 +36,7 @@ export interface FetchAlertsArgs { pageIndex: number; pageSize: number; }; - onLoaded?: () => void; + onLoaded?: (alerts: Alerts) => void; onPageChange: (pagination: RuleRegistrySearchRequestPagination) => void; runtimeMappings?: MappingRuntimeFields; sort: SortCombinations[]; @@ -259,13 +259,13 @@ const useFetchAlerts = ({ totalAlerts, }); dispatch({ type: 'loading', loading: false }); - onLoaded?.(); + onLoaded?.(alerts); searchSubscription$.current.unsubscribe(); } }, error: (msg) => { dispatch({ type: 'loading', loading: false }); - onLoaded?.(); + onLoaded?.([]); data.search.showError(msg); searchSubscription$.current.unsubscribe(); }, From 78de8a82fb7f10fca7e9c987e3f6d6889082d3f9 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 6 Jun 2024 17:39:42 +0000 Subject: [PATCH 02/20] WIP allow deleting more than 1 note at a time --- .../timeline/delete_note/delete_note_route.ts | 1 + .../common/api/timeline/model/api.ts | 3 ++ .../persist_note/persist_note_route.ts | 3 ++ .../types/timeline/note/saved_object.ts | 3 ++ .../lib/timeline/routes/notes/delete_note.ts | 25 ++++++--- .../lib/timeline/routes/notes/get_notes.ts | 52 ++++++++++++------- .../saved_object/notes/saved_object.ts | 41 ++++++++++++--- .../timeline/saved_object_mappings/notes.ts | 9 ++++ .../server/lib/timeline/utils/common.ts | 10 +++- 9 files changed, 112 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route.ts b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route.ts index af5dbaf394d81..717440fa0717a 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route.ts @@ -10,4 +10,5 @@ import { unionWithNullType } from '../../../utility_types'; export const deleteNoteSchema = runtimeTypes.partial({ noteId: unionWithNullType(runtimeTypes.string), + noteIds: unionWithNullType(runtimeTypes.array(runtimeTypes.string)), }); diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index 3e69bd14b646c..6296c0dd18ba7 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -34,6 +34,9 @@ export const BareNoteSchema = runtimeTypes.intersection([ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), + eventIngested: unionWithNullType(runtimeTypes.string), + eventTimestamp: unionWithNullType(runtimeTypes.string), + eventDataView: unionWithNullType(runtimeTypes.string), }), ]); diff --git a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts index a4426467637fa..e6f8b9cc94fd3 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/persist_note/persist_note_route.ts @@ -28,6 +28,9 @@ export const persistNoteWithoutRefSchema = runtimeTypes.intersection([ overrideOwner: unionWithNullType(runtimeTypes.boolean), noteId: unionWithNullType(runtimeTypes.string), version: unionWithNullType(runtimeTypes.string), + eventIngested: unionWithNullType(runtimeTypes.string), + eventTimestamp: unionWithNullType(runtimeTypes.string), + eventDataView: unionWithNullType(runtimeTypes.string), }), ]); diff --git a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts index 1de998559e76f..08d7b9527e757 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts @@ -22,6 +22,9 @@ const SavedNoteRuntimeType = runtimeTypes.partial({ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), + eventIngested: unionWithNullType(runtimeTypes.string), + eventTimestamp: unionWithNullType(runtimeTypes.string), + eventDataView: unionWithNullType(runtimeTypes.string), }); /** diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts index d54df1c7830d0..f9476a14c2a08 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/delete_note.ts @@ -46,15 +46,26 @@ export const deleteNoteRoute = ( try { const frameworkRequest = await buildFrameworkRequest(context, security, request); const noteId = request.body?.noteId ?? ''; + const noteIds = request.body?.noteIds ?? null; + if (noteIds != null) { + const res = await deleteNote({ + request: frameworkRequest, + noteIds, + }); - const res = await deleteNote({ - request: frameworkRequest, - noteId, - }); + return response.ok({ + body: { data: { persistNote: res } }, + }); + } else { + const res = await deleteNote({ + request: frameworkRequest, + noteIds: [noteId], + }); - return response.ok({ - body: { data: { persistNote: res } }, - }); + return response.ok({ + body: { data: { persistNote: res } }, + }); + } } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index e0180e1fe5d2b..dd85228452273 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -13,9 +13,7 @@ import type { ConfigType } from '../../../..'; import type { SetupPlugins } from '../../../../plugin'; import { buildSiemResponse } from '../../../detection_engine/routes/utils'; - -import { CustomHttpRequestError } from '../../../../utils/custom_http_request_error'; -import { buildFrameworkRequest, getNotesByDocumentIds, throwErrors, escapeHatch } from '../../utils/common'; +import { buildFrameworkRequest, getNotesPaginated } from '../../utils/common'; import { getAllSavedNote } from '../../saved_object/notes'; import { noteSavedObjectType } from '../../saved_object_mappings/notes'; @@ -35,31 +33,47 @@ export const getNotesByDocumentIdsRoute = ( .addVersion( { validate: { - request: { query: escapeHatch }, + request: { query: getNotesPaginated }, }, version: '2023-10-31', }, async (context, request, response) => { - const customHttpRequestError = (message: string) => - new CustomHttpRequestError(message, 400); try { + const queryParams = request.query; const frameworkRequest = await buildFrameworkRequest(context, security, request); - const alertIds = request.query?.alertIds ?? null; + const alertIds = queryParams.alertIds ?? null; console.log('alertIds:', alertIds); - // const pageSize = queryParams?.page_size ? parseInt(queryParams.page_size, 10) : null; - // const pageIndex = queryParams?.page_index ? parseInt(queryParams.page_index, 10) : null; - // const search = queryParams?.search ?? null; - // const sortField = queryParams?.sort_field ?? null; - // const sortOrder = queryParams?.sort_order ?? null; - const alertIdSearchString = alertIds?.join(' | '); - const options = { - type: noteSavedObjectType, - search: alertIdSearchString, - }; - const res = await getAllSavedNote(frameworkRequest, options); + if (alertIds != null && Array.isArray(alertIds)) { + const alertIdSearchString = alertIds?.join(' | '); + const options = { + type: noteSavedObjectType, + search: alertIdSearchString, + }; + const res = await getAllSavedNote(frameworkRequest, options); - return response.ok({ body: res ?? {} }); + return response.ok({ body: res ?? {} }); + } else { + const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; + const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1; + const search = queryParams?.search; + const sortField = queryParams?.sortField; + const sortOrder = queryParams?.sortOrder; + const filter = queryParams?.filter; + const options = { + type: noteSavedObjectType, + perPage, + page, + search, + sortField, + sortOrder, + filter, + }; + console.log(options); + const res = await getAllSavedNote(frameworkRequest, options); + return response.ok({ body: res ?? {} }); + } } catch (err) { + console.log('err:', err); const error = transformError(err); const siemResponse = buildSiemResponse(response); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 2d4e67b66e89f..0d2bc1ec2d67b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -49,14 +49,19 @@ export const deleteNotesByTimelineId = async (request: FrameworkRequest, timelin export const deleteNote = async ({ request, - noteId, + noteIds, }: { request: FrameworkRequest; - noteId: string; + noteIds: string[]; }) => { const savedObjectsClient = (await request.context.core).savedObjects.client; - - await savedObjectsClient.delete(noteSavedObjectType, noteId); + const noteObjects = noteIds.map((id) => { + return { + id, + type: noteSavedObjectType, + }; + }); + await savedObjectsClient.bulkDelete(noteObjects); }; export const getNote = async (request: FrameworkRequest, noteId: string): Promise => { @@ -136,9 +141,27 @@ const createNote = async ({ noteFieldsMigrator.extractFieldsToReferences({ data: noteWithCreator, }); - - console.log('migratedAttributes:', migratedAttributes); - + console.log('references:', references); + if (references.length === 0) { + // Limit unassociated events to 1000 + const notesCount = await savedObjectsClient.find({ + type: noteSavedObjectType, + hasNoReference: { type: timelineSavedObjectType, id: '*' }, + }); + console.log('notesCount:', notesCount.total); + if (notesCount.total >= 1000) { + return { + code: 403, + message: 'Cannot create more than 1000 notes without associating them to a timeline', + note: { + ...note, + noteId: uuidv1(), + version: '', + timelineId: '', + }, + }; + } + } const noteAttributes: SavedObjectNoteWithoutExternalRefs = { eventId: migratedAttributes.eventId, note: migratedAttributes.note, @@ -253,7 +276,6 @@ export const getAllSavedNote = async ( }; export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => { - console.log(savedObject, 'savedObject'); return pipe( SavedObjectNoteRuntimeType.decode(savedObject), map((savedNote) => { @@ -267,6 +289,9 @@ export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => { createdBy: savedNote.attributes.createdBy, updated: savedNote.attributes.updated, updatedBy: savedNote.attributes.updatedBy, + eventIngested: savedNote.attributes.eventIngested, + eventTimestamp: savedNote.attributes.eventTimestamp, + eventDataView: savedNote.attributes.eventDataView, }; }), fold((errors) => { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts index 0a8f59adfa19f..7cd654af217be 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts @@ -31,6 +31,15 @@ export const noteSavedObjectMappings: SavedObjectsType['mappings'] = { updatedBy: { type: 'text', }, + eventIngested: { + type: 'date', + }, + eventTimestamp: { + type: 'date', + }, + eventDataView: { + type: 'text', + }, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts index 7aa2cacfa4ede..b997ac546c87c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts @@ -39,7 +39,15 @@ export const buildFrameworkRequest = async ( export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -export const getNotesByDocumentIds = schema.object({ alertIds: schema.arrayOf(schema.string()) }); +export const getNotesPaginated = schema.object({ + alertIds: schema.maybe(schema.arrayOf(schema.string())), + page: schema.maybe(schema.string()), + perPage: schema.maybe(schema.string()), + search: schema.maybe(schema.string()), + sortField: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.oneOf([schema.literal('asc'), schema.literal('desc')])), + filter: schema.maybe(schema.string()), +}); type ErrorFactory = (message: string) => Error; From c187da9089f0bd69d9777a38abeb9fff17cf9715 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 10 Jun 2024 23:38:56 +0000 Subject: [PATCH 03/20] Limit notes to 1000 via fake reference --- .../lib/timeline/saved_object/notes/saved_object.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 0d2bc1ec2d67b..8a3733dce4f50 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -30,6 +30,8 @@ import { noteSavedObjectType } from '../../saved_object_mappings/notes'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import { noteFieldsMigrator } from './field_migrator'; +const UNASSOCIATED_NOTES_TIMELINE_ID = 'non-existent-timeline-id'; + export const deleteNotesByTimelineId = async (request: FrameworkRequest, timelineId: string) => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, @@ -141,14 +143,12 @@ const createNote = async ({ noteFieldsMigrator.extractFieldsToReferences({ data: noteWithCreator, }); - console.log('references:', references); if (references.length === 0) { // Limit unassociated events to 1000 const notesCount = await savedObjectsClient.find({ type: noteSavedObjectType, - hasNoReference: { type: timelineSavedObjectType, id: '*' }, + hasReference: { type: timelineSavedObjectType, id: UNASSOCIATED_NOTES_TIMELINE_ID }, }); - console.log('notesCount:', notesCount.total); if (notesCount.total >= 1000) { return { code: 403, @@ -160,6 +160,12 @@ const createNote = async ({ timelineId: '', }, }; + } else { + references.push({ + type: timelineSavedObjectType, + name: 'associated', + id: UNASSOCIATED_NOTES_TIMELINE_ID, + }); } } const noteAttributes: SavedObjectNoteWithoutExternalRefs = { From e43e53ff9164090a710ac03adbd823a1351b46a3 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Tue, 11 Jun 2024 04:58:44 +0000 Subject: [PATCH 04/20] Fix validation and GET for 1 id --- .../lib/timeline/routes/notes/get_notes.ts | 26 +++++++++++++------ .../server/lib/timeline/utils/common.ts | 2 +- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index dd85228452273..4b3809ee81895 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -43,15 +43,25 @@ export const getNotesByDocumentIdsRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const alertIds = queryParams.alertIds ?? null; console.log('alertIds:', alertIds); - if (alertIds != null && Array.isArray(alertIds)) { - const alertIdSearchString = alertIds?.join(' | '); - const options = { - type: noteSavedObjectType, - search: alertIdSearchString, - }; - const res = await getAllSavedNote(frameworkRequest, options); + if (alertIds != null) { + if (Array.isArray(alertIds)) { + const alertIdSearchString = alertIds?.join(' | '); + const options = { + type: noteSavedObjectType, + search: alertIdSearchString, + }; + const res = await getAllSavedNote(frameworkRequest, options); - return response.ok({ body: res ?? {} }); + return response.ok({ body: res ?? {} }); + } else { + const options = { + type: noteSavedObjectType, + search: alertIds, + }; + const res = await getAllSavedNote(frameworkRequest, options); + + return response.ok({ body: res ?? {} }); + } } else { const perPage = queryParams?.perPage ? parseInt(queryParams.perPage, 10) : 10; const page = queryParams?.page ? parseInt(queryParams.page, 10) : 1; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts index b997ac546c87c..97cb476176e7f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/utils/common.ts @@ -40,7 +40,7 @@ export const buildFrameworkRequest = async ( export const escapeHatch = schema.object({}, { unknowns: 'allow' }); export const getNotesPaginated = schema.object({ - alertIds: schema.maybe(schema.arrayOf(schema.string())), + alertIds: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])), page: schema.maybe(schema.string()), perPage: schema.maybe(schema.string()), search: schema.maybe(schema.string()), From 964a6395e87b46af468dcf6a20f5d614cfb66c65 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 05:31:01 +0000 Subject: [PATCH 05/20] Add tests, fix types, remove demo frontend changes --- .../common/api/timeline/model/api.ts | 3 - .../types/timeline/note/saved_object.ts | 3 - .../components/header_actions/actions.tsx | 9 +- .../header_actions/add_note_icon_item.tsx | 4 +- .../public/common/store/app/actions.ts | 12 -- .../public/common/store/app/model.ts | 4 - .../public/common/store/app/reducer.ts | 25 +-- .../public/common/store/app/selectors.ts | 20 -- .../components/alerts_table/index.tsx | 12 -- .../security_solution/public/notes/api/api.ts | 13 +- .../public/notes/store/normalize.ts | 9 +- .../create_timeline_middlewares.ts | 2 - .../store/middlewares/unassociated_notes.ts | 39 ---- .../timeline/routes/notes/get_notes.test.ts | 85 ++++++++ .../lib/timeline/routes/notes/get_notes.ts | 3 - .../lib/timeline/routes/notes/persist_note.ts | 2 +- .../saved_object/notes/saved_object.test.ts | 196 +++++++++++++++++- .../saved_object/notes/saved_object.ts | 23 +- .../timeline/saved_object/timelines/index.ts | 2 - .../timeline/saved_object_mappings/notes.ts | 9 - .../alerts_table/alerts_table_state.tsx | 3 - .../alerts_table/hooks/use_fetch_alerts.tsx | 6 +- 22 files changed, 306 insertions(+), 178 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts create mode 100644 x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts diff --git a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts index 6296c0dd18ba7..3e69bd14b646c 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/model/api.ts +++ b/x-pack/plugins/security_solution/common/api/timeline/model/api.ts @@ -34,9 +34,6 @@ export const BareNoteSchema = runtimeTypes.intersection([ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), - eventIngested: unionWithNullType(runtimeTypes.string), - eventTimestamp: unionWithNullType(runtimeTypes.string), - eventDataView: unionWithNullType(runtimeTypes.string), }), ]); diff --git a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts index 08d7b9527e757..1de998559e76f 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/note/saved_object.ts @@ -22,9 +22,6 @@ const SavedNoteRuntimeType = runtimeTypes.partial({ createdBy: unionWithNullType(runtimeTypes.string), updated: unionWithNullType(runtimeTypes.number), updatedBy: unionWithNullType(runtimeTypes.string), - eventIngested: unionWithNullType(runtimeTypes.string), - eventTimestamp: unionWithNullType(runtimeTypes.string), - eventDataView: unionWithNullType(runtimeTypes.string), }); /** diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index dece1b696ea70..9305d4cf544f7 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import styled from 'styled-components'; @@ -19,7 +19,6 @@ import { } from '../../../timelines/components/timeline/body/helpers'; import { getScopedActions, isTimelineScope } from '../../../helpers'; import { useIsInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; -import { appSelectors } from '../../store'; import { timelineActions } from '../../../timelines/store'; import type { ActionProps, OnPinEvent } from '../../../../common/types'; import { TimelineId } from '../../../../common/types'; @@ -105,10 +104,7 @@ const ActionsComponent: React.FC = ({ !(ecsData.event?.kind?.includes('event') && ecsData.agent?.type?.includes('endpoint')) ); }, [ecsData, eventType]); - const nonAssociatedNotes = useSelector((state) => - appSelectors.nonAssociatedNotesByIdSelector(state, eventId) - ); - console.log('nonAssociatedNotes', nonAssociatedNotes); + const isDisabled = !useIsInvestigateInResolverActionEnabled(ecsData); const { setGlobalFullScreen } = useGlobalFullScreen(); const { setTimelineFullScreen } = useTimelineFullScreen(); @@ -263,7 +259,6 @@ const ActionsComponent: React.FC = ({ toggleShowNotes={() => {}} timelineType={timelineType} eventId={eventId} - eventCount={nonAssociatedNotes.length} /> )} {!isEventViewer && toggleShowNotes && ( diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx index 99b63775aad93..59e7992cada8b 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/add_note_icon_item.tsx @@ -18,7 +18,6 @@ interface AddEventNoteActionProps { timelineType: TimelineType; toggleShowNotes: () => void; eventId?: string; - eventCount?: number; } const AddEventNoteActionComponent: React.FC = ({ @@ -27,7 +26,6 @@ const AddEventNoteActionComponent: React.FC = ({ timelineType, toggleShowNotes, eventId, - eventCount, }) => { const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const toolTip = @@ -42,7 +40,7 @@ const AddEventNoteActionComponent: React.FC = ({ showNotes={showNotes} timelineType={timelineType} toggleShowNotes={toggleShowNotes} - toolTip={eventCount ?? toolTip} + toolTip={toolTip} eventId={eventId} /> diff --git a/x-pack/plugins/security_solution/public/common/store/app/actions.ts b/x-pack/plugins/security_solution/public/common/store/app/actions.ts index 13872b2c91c23..2ddf3b34475a7 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/actions.ts @@ -23,18 +23,6 @@ export const addError = actionCreator<{ id: string; title: string; message: stri export const removeError = actionCreator<{ id: string }>('REMOVE_ERRORS'); -export const setEventIdsToFetchNotesFor = actionCreator<{ eventIds: string[] }>( - 'SET_EVENT_IDS_TO_FETCH_NOTES_FOR' -); - -export const setNonTimelineEventNotesLoading = actionCreator<{ isLoading: boolean }>( - 'SET_NON_TIMELINE_EVENT_NOTES_LOADING' -); - -export const serverReturnedNonAssociatedNotes = actionCreator<{ notes: Note[] }>( - 'SERVER_RETURNED_NON_ASSOCIATED_NOTES' -); - export const addErrorHash = actionCreator<{ id: string; hash: string; diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index e3d6c02bf65ce..69a20da4598b1 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -28,8 +28,4 @@ export interface AppModel { notesById: NotesById; errors: ErrorState; enableExperimental: ExperimentalFeatures; - eventIdsToFetch: string[]; - nonTimelineEventNotesLoading: boolean; - nonTimelineEventNotesError: string | null; - nonAssociatedNotes: Note[]; } diff --git a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts index 57248c7deb247..4928fc9e6e150 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts @@ -9,16 +9,7 @@ import { reducerWithInitialState } from 'typescript-fsa-reducers'; import type { Note } from '../../lib/note'; -import { - addError, - addErrorHash, - addNotes, - removeError, - updateNote, - deleteNote, - setEventIdsToFetchNotesFor, - serverReturnedNonAssociatedNotes, -} from './actions'; +import { addError, addErrorHash, addNotes, removeError, updateNote, deleteNote } from './actions'; import type { AppModel, NotesById } from './model'; import { allowedExperimentalValues } from '../../../../common/experimental_features'; @@ -28,8 +19,6 @@ export const initialAppState: AppState = { notesById: {}, errors: [], enableExperimental: { ...allowedExperimentalValues }, - eventIdsToFetch: [], - nonAssociatedNotes: [], }; interface UpdateNotesByIdParams { @@ -89,16 +78,4 @@ export const appReducer = reducerWithInitialState(initialAppState) ], }; }) - .case(setEventIdsToFetchNotesFor, (state, { eventIds }) => { - return { - ...state, - eventIdsToFetch: eventIds, - }; - }) - .case(serverReturnedNonAssociatedNotes, (state, { notes }) => { - return { - ...state, - nonAssociatedNotes: notes, - }; - }) .build(); diff --git a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts index 98504ec9af1ce..47df2527f9edd 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/selectors.ts @@ -48,24 +48,4 @@ export const notesByIdsSelector = () => export const selectNotesAsCommentsListSelector = () => createSelector(selectNotesById, getNotesAsCommentsList); -export const selectEventIdsToFetchNotes = (state: State): string[] => state.app.eventIdsToFetch; - -export const selectNonAssociatedNotes = (state: State): Note[] => state.app.nonAssociatedNotes; - -export const nonAssociatedNotesSelector = createSelector( - selectNonAssociatedNotes, - (notes) => notes -); - -export const nonAssociatedNotesByIdSelector = createSelector( - nonAssociatedNotesSelector, - (state: State, eventId: string) => eventId, - (notes, eventId) => notes.filter((note) => note.eventId === eventId) -); - -export const selectEventIdsToFetchNotesSelector = createSelector( - selectEventIdsToFetchNotes, - (ids) => ids -); - export const errorsSelector = () => createSelector(getErrors, (errors) => errors); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index f417d83224454..5fc8364b0d7eb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -25,7 +25,6 @@ import { import { useGlobalTime } from '../../../common/containers/use_global_time'; import { useLicense } from '../../../common/hooks/use_license'; import { VIEW_SELECTION } from '../../../../common/constants'; -import { appActions } from '../../../common/store/app'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; @@ -266,15 +265,6 @@ export const AlertsTableComponent: FC = ({ }; }, []); - const onLoaded = useCallback( - (alerts) => { - const alertIds = alerts.map((alert) => alert._id); - console.log('Loaded alerts:', alertIds); - dispatch(appActions.setEventIdsToFetchNotesFor({ eventIds: alertIds })); - }, - [dispatch] - ); - const alertStateProps: AlertsTableStateProps = useMemo( () => ({ alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, @@ -290,7 +280,6 @@ export const AlertsTableComponent: FC = ({ browserFields: finalBrowserFields, onUpdate: onAlertTableUpdate, cellContext, - onLoaded, runtimeMappings, toolbarVisibility: { showColumnSelector: !isEventRenderedView, @@ -311,7 +300,6 @@ export const AlertsTableComponent: FC = ({ runtimeMappings, isEventRenderedView, cellContext, - onLoaded, ] ); diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index c1090de4fe631..4178b6de612be 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -35,11 +35,14 @@ export const createNote = async ({ note }: { note: BareNote }) => { * Fetches all the notes for a document id */ export const fetchNotesByDocumentId = async (documentId: string) => { - const response = { - totalCount: 1, - notes: [generateNoteMock(documentId)], - }; - return response.notes; + const response = await KibanaServices.get().http.get<{ notes: Note[]; totalCount: number }>( + NOTE_URL, + { + query: { alertIds: [documentId] }, + version: '2023-10-31', + } + ); + return response; }; // TODO remove when the API is available diff --git a/x-pack/plugins/security_solution/public/notes/store/normalize.ts b/x-pack/plugins/security_solution/public/notes/store/normalize.ts index 2b433a35d7c86..3fe41052fe4c4 100644 --- a/x-pack/plugins/security_solution/public/notes/store/normalize.ts +++ b/x-pack/plugins/security_solution/public/notes/store/normalize.ts @@ -46,9 +46,12 @@ export const normalizeEntity = (res: Note): NormalizedEntity => ({ /** * Normalizes an array of notes */ -export const normalizeEntities = (res: Note[]): NormalizedEntities => ({ +export const normalizeEntities = (res: { + notes: Note[]; + totalCount: number; +}): NormalizedEntities => ({ entities: { - notes: res.reduce((obj, item) => Object.assign(obj, { [item.noteId]: item }), {}), + notes: res.notes.reduce((obj, item) => Object.assign(obj, { [item.noteId]: item }), {}), }, - result: res.map((note) => note.noteId), + result: res.notes.map((note) => note.noteId), }); diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts index 994c9bc87eb47..d473f509c688c 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/create_timeline_middlewares.ts @@ -10,7 +10,6 @@ import type { CoreStart } from '@kbn/core/public'; import { timelineChangedMiddleware } from './timeline_changed'; import { favoriteTimelineMiddleware } from './timeline_favorite'; import { addNoteToTimelineMiddleware } from './timeline_note'; -import { displayUnassociatedNotesMiddleware } from './unassociated_notes'; import { addPinnedEventToTimelineMiddleware } from './timeline_pinned_event'; import { saveTimelineMiddleware } from './timeline_save'; @@ -21,6 +20,5 @@ export function createTimelineMiddlewares(kibana: CoreStart) { addNoteToTimelineMiddleware(kibana), addPinnedEventToTimelineMiddleware(kibana), saveTimelineMiddleware(kibana), - displayUnassociatedNotesMiddleware(kibana), ]; } diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts deleted file mode 100644 index daa84dfbf343e..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/unassociated_notes.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 type { Action, Middleware } from 'redux'; -import type { CoreStart } from '@kbn/core/public'; - -import {appActions } from '../../../common/store/app'; -import type { State } from '../../../common/store/types'; -import { setEventIdsToFetchNotesFor } from '../../../common/store/app/actions'; -import type { Note } from '../../../common/lib/note'; -import { getNotesByIds } from '../../containers/notes/api'; - -export const displayUnassociatedNotesMiddleware: (kibana: CoreStart) => Middleware<{}, State> = - (kibana: CoreStart) => (store) => (next) => async (action: Action) => { - // perform the action - const ret = next(action); - - if (action.type === setEventIdsToFetchNotesFor.type) { - const eventIds = action.payload.eventIds; - console.log('hello from middleware', eventIds); - store.dispatch(appActions.setNonTimelineEventNotesLoading({ isLoading: true })); - - try { - const response = await getNotesByIds(eventIds); - const notes: Note[] = response.notes; - console.log('notes', notes); - store.dispatch(appActions.serverReturnedNonAssociatedNotes({ notes })); - } catch (error) { - console.error('Error fetching notes:', error); - } - store.dispatch(appActions.setNonTimelineEventNotesLoading({ isLoading: false })); - } - - return ret; - }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts new file mode 100644 index 0000000000000..9bad57f42b465 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.test.ts @@ -0,0 +1,85 @@ +/* + * 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 type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import { + serverMock, + requestContextMock, + createMockConfig, + requestMock, +} from '../../../detection_engine/routes/__mocks__'; +import { NOTE_URL } from '../../../../../common/constants'; +import type { getNotesPaginated } from '../../utils/common'; +import { mockGetCurrentUser } from '../../__mocks__/import_timelines'; + +const getAllNotesRequest = (query?: typeof getNotesPaginated) => + requestMock.create({ + method: 'get', + path: NOTE_URL, + query, + }); + +const createMockedNotes = (numberOfNotes: number) => { + return Array.from({ length: numberOfNotes }, (_, index) => { + return { + id: index + 1, + timelineId: 'timeline', + eventId: 'event', + note: `test note ${index}`, + created: 1280120812453, + createdBy: 'test', + updated: 108712801280, + updatedBy: 'test', + }; + }); +}; + +describe('get notes route', () => { + let server: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetAllSavedNote: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + + securitySetup = { + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown as SecurityPluginSetup; + + mockGetAllSavedNote = jest.fn(); + jest.doMock('../../saved_object/notes', () => ({ + getAllSavedNote: mockGetAllSavedNote, + })); + const getNotesByDocumentIdsRoute = jest.requireActual('.').getNotesByDocumentIdsRoute; + getNotesByDocumentIdsRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should return a list of notes and the count by default', async () => { + mockGetAllSavedNote.mockResolvedValue({ + notes: createMockedNotes(5), + totalCount: 5, + }); + + const response = await server.inject( + getAllNotesRequest(), + requestContextMock.convertContext(context) + ); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + totalCount: 5, + notes: createMockedNotes(5), + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 4b3809ee81895..81bef9dd1d521 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -42,7 +42,6 @@ export const getNotesByDocumentIdsRoute = ( const queryParams = request.query; const frameworkRequest = await buildFrameworkRequest(context, security, request); const alertIds = queryParams.alertIds ?? null; - console.log('alertIds:', alertIds); if (alertIds != null) { if (Array.isArray(alertIds)) { const alertIdSearchString = alertIds?.join(' | '); @@ -78,12 +77,10 @@ export const getNotesByDocumentIdsRoute = ( sortOrder, filter, }; - console.log(options); const res = await getAllSavedNote(frameworkRequest, options); return response.ok({ body: res ?? {} }); } } catch (err) { - console.log('err:', err); const error = transformError(err); const siemResponse = buildSiemResponse(response); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts index 627b0d327a1f5..6d2d1c6c6f3f5 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts @@ -20,7 +20,7 @@ import type { ConfigType } from '../../../..'; import { buildSiemResponse } from '../../../detection_engine/routes/utils'; import { buildFrameworkRequest } from '../../utils/common'; -import { persistNoteSchema, persistNoteWithoutRefSchema } from '../../../../../common/api/timeline'; +import { persistNoteWithoutRefSchema } from '../../../../../common/api/timeline'; import { persistNote } from '../../saved_object/notes'; export const persistNoteRoute = ( diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts index fef377a91450d..afde2fc424ff7 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts @@ -6,11 +6,39 @@ */ import type { AuthenticatedUser } from '@kbn/security-plugin/common'; +import { coreMock, httpServerMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import type { KibanaRequest, RequestHandlerContext, SavedObject } from '@kbn/core/server'; +import type { SavedObjectNoteWithoutExternalRefs } from '../../../../../common/types/timeline/note/saved_object'; +import type { FrameworkRequest } from '../../../framework'; +import { internalFrameworkRequest } from '../../../framework'; import type { Note } from '../../../../../common/api/timeline'; +import { requestContextMock } from '../../../detection_engine/routes/__mocks__/request_context'; +import { noteFieldsMigrator } from './field_migrator'; +import { pickSavedNote, persistNote, createNote, updateNote } from './saved_object'; -import { pickSavedNote } from './saved_object'; +jest.mock('uuid', () => ({ + v1: jest.fn().mockReturnValue('7ba7a520-03f4-11eb-9d9d-ffba20fabba8'), +})); -describe('saved_object', () => { +jest.mock('./saved_object', () => { + const originalModule = jest.requireActual('./saved_object'); + return { + ...originalModule, + createNote: jest.fn(originalModule.createNote), + updateNote: jest.fn(originalModule.updateNote), + persistNote: jest.fn(originalModule.persistNote), + }; +}); + +jest.mock('./field_migrator', () => ({ + noteFieldsMigrator: { + extractFieldsToReferences: jest.fn(), + populateFieldsFromReferences: jest.fn(), + populateFieldsFromReferencesForPatch: jest.fn(), + }, +})); + +describe('saved_object pick', () => { const mockDateNow = new Date('2020-04-03T23:00:00.000Z').valueOf(); const getMockSavedNote = (): Note => ({ noteId: '7ba7a520-03f4-11eb-9d9d-ffba20fabba8', @@ -125,3 +153,167 @@ describe('saved_object', () => { }); }); }); + +describe('persistNote', () => { + const mockDateNow = new Date('2020-04-03T23:00:00.000Z').valueOf(); + const mockMigratedAttributes = { + eventId: 'event1', + note: 'Test note', + created: 112089832019, + createdBy: 'user1', + updated: 120812001801, + updatedBy: 'user1', + }; + const mockReferences = [{ id: '', name: 'timeline', type: 'timeline' }]; + + const mockNoteSavedObject: SavedObject = { + attributes: mockMigratedAttributes, + type: 'siem-ui-timeline-note', + id: 'test-id', + references: [ + { + id: '', + name: 'timelineId', + type: 'siem-ui-timeline', + }, + ], + managed: false, + version: 'WzQ0ODEsMV0=', + namespaces: ['default'], + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '8.0.0', + updated_at: '2024-06-25T22:56:01.354Z', + updated_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + created_at: '2024-06-25T22:56:01.354Z', + created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', + }; + // let mockSavedObjectClient: jest.Mocked; + const mockSavedObjectClient = savedObjectsClientMock.create(); + const core = coreMock.createRequestHandlerContext(); + const context = { + ...requestContextMock.create(), + core: { + ...core, + savedObjects: { + ...core.savedObjects, + client: mockSavedObjectClient, + }, + }, + resolve: jest.fn(), + } as unknown as RequestHandlerContext; + const mockNote = { eventId: 'id', note: 'test note', timelineId: '' }; + const mockRequest: FrameworkRequest = { + ...httpServerMock.createKibanaRequest< + KibanaRequest< + unknown, + unknown, + { note: { eventId: string; note: string; timelineId: string | null } } + >, + RequestHandlerContext, + AuthenticatedUser + >({ + body: { + note: mockNote, + }, + }), + [internalFrameworkRequest]: httpServerMock.createKibanaRequest(), + context, + user: { + username: 'test', + authentication_provider: { type: 'test', name: 'test' }, + full_name: 'test', + authentication_type: 'test', + authentication_realm: { type: 'test', name: 'test' }, + lookup_realm: { type: 'test', name: 'test' }, + enabled: true, + elastic_cloud_user: false, + roles: ['superuser'], + }, + }; + const mockNoteResponse = { + ...mockNote, + ...mockMigratedAttributes, + noteId: 'test-id', + version: 'WzQ0ODEsMV0=', + }; + beforeAll(() => { + Date = jest.fn(() => ({ + valueOf: jest.fn().mockReturnValue(mockDateNow), + })) as unknown as DateConstructor; + }); + beforeEach(() => { + jest.clearAllMocks(); + mockSavedObjectClient.get.mockResolvedValue(mockNoteSavedObject); + mockSavedObjectClient.create.mockResolvedValue(mockNoteSavedObject); + (noteFieldsMigrator.extractFieldsToReferences as jest.Mock).mockReturnValue({ + transformedFields: mockMigratedAttributes, + references: mockReferences, + }); + (noteFieldsMigrator.populateFieldsFromReferences as jest.Mock).mockReturnValue({ + ...mockNoteSavedObject, + attributes: { ...mockNoteSavedObject.attributes, timelineId: '' }, + }); + (noteFieldsMigrator.populateFieldsFromReferencesForPatch as jest.Mock).mockReturnValue({ + ...mockNoteSavedObject, + attributes: { ...mockNoteSavedObject.attributes, timelineId: '' }, + }); + }); + it('should call createNote when noteId is null', async () => { + mockSavedObjectClient.find.mockResolvedValue({ + total: 0, + saved_objects: [], + per_page: 0, + page: 0, + }); + + (createNote as jest.Mock).mockResolvedValue({ code: 200, message: 'success', note: mockNote }); + + const result = await persistNote({ request: mockRequest, noteId: null, note: mockNote }); + + expect(result).toEqual({ + code: 200, + message: 'success', + note: mockNoteResponse, + }); + }); + + it('should call updateNote when noteId is provided', async () => { + const noteId = 'test-id'; + mockSavedObjectClient.find.mockResolvedValue({ + total: 0, + saved_objects: [], + per_page: 0, + page: 0, + }); + + (updateNote as jest.Mock).mockResolvedValue({ code: 200, message: 'success', note: mockNote }); + + const result = await persistNote({ request: mockRequest, noteId, note: mockNote }); + + expect(result).toEqual({ code: 200, message: 'success', note: mockNoteResponse }); + }); + + it('should handle 403 errors', async () => { + mockSavedObjectClient.find.mockResolvedValue({ + total: 1001, + saved_objects: [], + per_page: 0, + page: 0, + }); + (createNote as jest.Mock).mockResolvedValue({ + code: 403, + message: 'Cannot create more than 1000 notes without associating them to a timeline', + note: mockNote, + }); + + const result = await persistNote({ request: mockRequest, noteId: null, note: mockNote }); + + expect(result.code).toBe(403); + expect(result.message).toBe( + 'Cannot create more than 1000 notes without associating them to a timeline' + ); + expect(result.note).toHaveProperty('noteId'); + expect(result.note).toHaveProperty('version', ''); + expect(result.note).toHaveProperty('timelineId', ''); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 8a3733dce4f50..6d28d60da9af2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -19,7 +19,6 @@ import { getUserDisplayName } from '@kbn/user-profile-components'; import { UNAUTHENTICATED_USER } from '../../../../../common/constants'; import type { Note, - // BareNote, BareNoteWithoutExternalRefs, ResponseNote, } from '../../../../../common/api/timeline'; @@ -30,8 +29,6 @@ import { noteSavedObjectType } from '../../saved_object_mappings/notes'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import { noteFieldsMigrator } from './field_migrator'; -const UNASSOCIATED_NOTES_TIMELINE_ID = 'non-existent-timeline-id'; - export const deleteNotesByTimelineId = async (request: FrameworkRequest, timelineId: string) => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, @@ -123,7 +120,7 @@ export const persistNote = async ({ } }; -const createNote = async ({ +export const createNote = async ({ request, noteId, note, @@ -143,11 +140,11 @@ const createNote = async ({ noteFieldsMigrator.extractFieldsToReferences({ data: noteWithCreator, }); - if (references.length === 0) { + if (references.length === 1 && references[0].id === '') { // Limit unassociated events to 1000 const notesCount = await savedObjectsClient.find({ type: noteSavedObjectType, - hasReference: { type: timelineSavedObjectType, id: UNASSOCIATED_NOTES_TIMELINE_ID }, + hasReference: { type: timelineSavedObjectType, id: '' }, }); if (notesCount.total >= 1000) { return { @@ -160,12 +157,6 @@ const createNote = async ({ timelineId: '', }, }; - } else { - references.push({ - type: timelineSavedObjectType, - name: 'associated', - id: UNASSOCIATED_NOTES_TIMELINE_ID, - }); } } const noteAttributes: SavedObjectNoteWithoutExternalRefs = { @@ -197,7 +188,7 @@ const createNote = async ({ }; }; -const updateNote = async ({ +export const updateNote = async ({ request, noteId, note, @@ -295,14 +286,10 @@ export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => { createdBy: savedNote.attributes.createdBy, updated: savedNote.attributes.updated, updatedBy: savedNote.attributes.updatedBy, - eventIngested: savedNote.attributes.eventIngested, - eventTimestamp: savedNote.attributes.eventTimestamp, - eventDataView: savedNote.attributes.eventDataView, }; }), fold((errors) => { - console.log('would throw here'); - // throw new Error(failure(errors).join('\n')); + throw new Error(failure(errors).join('\n')); }, identity) ); }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index 1cad9d3b2672f..89ba7907452e1 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -615,9 +615,7 @@ export const copyTimeline = async ( noteId: null, note: { ..._note, - timelineId: newTimelineId, }, - overrideOwner: false, }); }) ); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts index 7cd654af217be..0a8f59adfa19f 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object_mappings/notes.ts @@ -31,15 +31,6 @@ export const noteSavedObjectMappings: SavedObjectsType['mappings'] = { updatedBy: { type: 'text', }, - eventIngested: { - type: 'date', - }, - eventTimestamp: { - type: 'date', - }, - eventDataView: { - type: 'text', - }, }, }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index 31f93378ef9a6..00e29fef78f1c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -78,7 +78,6 @@ export type AlertsTableStateProps = { pageSize?: number; browserFields?: BrowserFields; onUpdate?: (args: TableUpdateHandlerArgs) => void; - onLoaded?: (alerts: Alerts) => void; runtimeMappings?: MappingRuntimeFields; showAlertStatusWithFlapping?: boolean; toolbarVisibility?: EuiDataGridToolBarVisibilityOptions; @@ -208,7 +207,6 @@ const AlertsTableStateWithQueryProvider = memo( gridStyle, browserFields: propBrowserFields, onUpdate, - onLoaded, runtimeMappings, showAlertStatusWithFlapping, toolbarVisibility, @@ -318,7 +316,6 @@ const AlertsTableStateWithQueryProvider = memo( query, pagination, onPageChange, - onLoaded, runtimeMappings, sort, skip: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx index c47c2d3207248..5a0a5efb18c32 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx @@ -36,7 +36,7 @@ export interface FetchAlertsArgs { pageIndex: number; pageSize: number; }; - onLoaded?: (alerts: Alerts) => void; + onLoaded?: () => void; onPageChange: (pagination: RuleRegistrySearchRequestPagination) => void; runtimeMappings?: MappingRuntimeFields; sort: SortCombinations[]; @@ -259,13 +259,13 @@ const useFetchAlerts = ({ totalAlerts, }); dispatch({ type: 'loading', loading: false }); - onLoaded?.(alerts); + onLoaded?.(); searchSubscription$.current.unsubscribe(); } }, error: (msg) => { dispatch({ type: 'loading', loading: false }); - onLoaded?.([]); + onLoaded?.(); data.search.showError(msg); searchSubscription$.current.unsubscribe(); }, From c2ddbba27c9a3e04aba320241f8204b80e2fdff3 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 05:36:54 +0000 Subject: [PATCH 06/20] Remove dangling frontend changes --- .../common/components/header_actions/actions.tsx | 10 ---------- .../components/header_actions/add_note_icon_item.tsx | 6 +++--- .../trigger_actions_alert_table/use_actions_column.tsx | 2 +- .../server/lib/timeline/routes/notes/persist_note.ts | 5 +---- .../timeline/saved_object/notes/saved_object.test.ts | 1 - .../sections/alerts_table/alerts_table_state.tsx | 3 +++ 6 files changed, 8 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index 9305d4cf544f7..16fabb97fb464 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -251,16 +251,6 @@ const ActionsComponent: React.FC = ({ /> )} - {timelineId === 'alerts-page' && ( - {}} - timelineType={timelineType} - eventId={eventId} - /> - )} {!isEventViewer && toggleShowNotes && ( <> = ({ eventId, }) => { const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); - const toolTip = - timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP; return ( @@ -40,7 +38,9 @@ const AddEventNoteActionComponent: React.FC = ({ showNotes={showNotes} timelineType={timelineType} toggleShowNotes={toggleShowNotes} - toolTip={toolTip} + toolTip={ + timelineType === TimelineType.template ? i18n.NOTES_DISABLE_TOOLTIP : i18n.NOTES_TOOLTIP + } eventId={eventId} /> diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx index fbb89c511109d..f0e5468b3a97e 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx @@ -24,7 +24,7 @@ export const getUseActionColumnHook = () => { const license = useLicense(); const isEnterprisePlus = license.isEnterprise(); - const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 4 : isEnterprisePlus ? 6 : 5; + const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 4 : isEnterprisePlus ? 5 : 4; const eventContext = useContext(StatefulEventContext); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts index 6d2d1c6c6f3f5..e4cf4b8a9c777 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/persist_note.ts @@ -11,10 +11,7 @@ import type { SecuritySolutionPluginRouter } from '../../../../types'; import { NOTE_URL } from '../../../../../common/constants'; import type { SetupPlugins } from '../../../../plugin'; -import { - // buildRouteValidationWithExcess, - buildRouteValidation, -} from '../../../../utils/build_validation/route_validation'; +import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; import type { ConfigType } from '../../../..'; import { buildSiemResponse } from '../../../detection_engine/routes/utils'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts index afde2fc424ff7..ed5b198932768 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.test.ts @@ -187,7 +187,6 @@ describe('persistNote', () => { created_at: '2024-06-25T22:56:01.354Z', created_by: 'u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0', }; - // let mockSavedObjectClient: jest.Mocked; const mockSavedObjectClient = savedObjectsClientMock.create(); const core = coreMock.createRequestHandlerContext(); const context = { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index 00e29fef78f1c..65353acf5c9a6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -78,6 +78,7 @@ export type AlertsTableStateProps = { pageSize?: number; browserFields?: BrowserFields; onUpdate?: (args: TableUpdateHandlerArgs) => void; + onLoaded?: () => void; runtimeMappings?: MappingRuntimeFields; showAlertStatusWithFlapping?: boolean; toolbarVisibility?: EuiDataGridToolBarVisibilityOptions; @@ -207,6 +208,7 @@ const AlertsTableStateWithQueryProvider = memo( gridStyle, browserFields: propBrowserFields, onUpdate, + onLoaded, runtimeMappings, showAlertStatusWithFlapping, toolbarVisibility, @@ -316,6 +318,7 @@ const AlertsTableStateWithQueryProvider = memo( query, pagination, onPageChange, + onLoaded, runtimeMappings, sort, skip: false, From 4a19aa8b7e64cd34c45d909ac48dca33e2ad6fba Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 14:47:17 +0000 Subject: [PATCH 07/20] PR feedback --- .../trigger_actions_alert_table/use_actions_column.tsx | 2 +- .../security_solution/public/notes/store/normalize.ts | 9 +++------ .../security_solution/public/notes/store/notes.slice.ts | 2 +- .../public/timelines/containers/notes/api.ts | 8 -------- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx index f0e5468b3a97e..840cc0efb5529 100644 --- a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx @@ -24,7 +24,7 @@ export const getUseActionColumnHook = () => { const license = useLicense(); const isEnterprisePlus = license.isEnterprise(); - const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 4 : isEnterprisePlus ? 5 : 4; + const ACTION_BUTTON_COUNT = tableId === TableId.alertsOnCasePage ? 3 : isEnterprisePlus ? 5 : 4; const eventContext = useContext(StatefulEventContext); diff --git a/x-pack/plugins/security_solution/public/notes/store/normalize.ts b/x-pack/plugins/security_solution/public/notes/store/normalize.ts index 3fe41052fe4c4..2b433a35d7c86 100644 --- a/x-pack/plugins/security_solution/public/notes/store/normalize.ts +++ b/x-pack/plugins/security_solution/public/notes/store/normalize.ts @@ -46,12 +46,9 @@ export const normalizeEntity = (res: Note): NormalizedEntity => ({ /** * Normalizes an array of notes */ -export const normalizeEntities = (res: { - notes: Note[]; - totalCount: number; -}): NormalizedEntities => ({ +export const normalizeEntities = (res: Note[]): NormalizedEntities => ({ entities: { - notes: res.notes.reduce((obj, item) => Object.assign(obj, { [item.noteId]: item }), {}), + notes: res.reduce((obj, item) => Object.assign(obj, { [item.noteId]: item }), {}), }, - result: res.notes.map((note) => note.noteId), + result: res.map((note) => note.noteId), }); diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 52a2805e765f6..b38f0983119e9 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -67,7 +67,7 @@ export const fetchNotesByDocumentId = createAsyncThunk< >('notes/fetchNotesByDocumentId', async (args) => { const { documentId } = args; const res = await fetchNotesByDocumentIdApi(documentId); - return normalizeEntities(res); + return normalizeEntities(res.notes); }); export const createNote = createAsyncThunk, { note: BareNote }, {}>( diff --git a/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts index 54d10cf1507e0..25cb18f574260 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/notes/api.ts @@ -34,11 +34,3 @@ export const persistNote = async ({ }); return response; }; - -export const getNotesByIds = async (documentIds: string[]) => { - const response = await KibanaServices.get().http.get(NOTE_URL, { - query: { alertIds: documentIds }, - version: '2023-10-31', - }); - return response; -}; From 628d7e1f6023ab19964c4c311550e8f367a828db Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 14:56:10 +0000 Subject: [PATCH 08/20] Fix failing test --- .../server/lib/timeline/saved_object/timelines/index.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts index f8ce73092a804..82686c2a2307b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts @@ -540,9 +540,7 @@ describe('saved_object', () => { noteId: null, note: expect.objectContaining({ ...note, - timelineId: mockResolvedTimeline.savedObjectId, }), - overrideOwner: false, }) ); From 564d02db7ab0227110ca3433951872d0aa3e1223 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 21:34:58 +0000 Subject: [PATCH 09/20] Revert type change --- .../lib/timeline/saved_object/notes/saved_object.ts | 9 +++++---- .../lib/timeline/saved_object/timelines/index.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index 6d28d60da9af2..a6df441cbc0ac 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -19,6 +19,7 @@ import { getUserDisplayName } from '@kbn/user-profile-components'; import { UNAUTHENTICATED_USER } from '../../../../../common/constants'; import type { Note, + BareNote, BareNoteWithoutExternalRefs, ResponseNote, } from '../../../../../common/api/timeline'; @@ -87,7 +88,7 @@ export const persistNote = async ({ }: { request: FrameworkRequest; noteId: string | null; - note: BareNoteWithoutExternalRefs; + note: BareNote | BareNoteWithoutExternalRefs; overrideOwner?: boolean; }): Promise => { try { @@ -128,7 +129,7 @@ export const createNote = async ({ }: { request: FrameworkRequest; noteId: string | null; - note: BareNoteWithoutExternalRefs; + note: BareNote | BareNoteWithoutExternalRefs; overrideOwner?: boolean; }) => { const savedObjectsClient = (await request.context.core).savedObjects.client; @@ -196,7 +197,7 @@ export const updateNote = async ({ }: { request: FrameworkRequest; noteId: string; - note: BareNoteWithoutExternalRefs; + note: BareNote | BareNoteWithoutExternalRefs; overrideOwner?: boolean; }) => { const savedObjectsClient = (await request.context.core).savedObjects.client; @@ -296,7 +297,7 @@ export const convertSavedObjectToSavedNote = (savedObject: unknown): Note => { export const pickSavedNote = ( noteId: string | null, - savedNote: BareNoteWithoutExternalRefs, + savedNote: BareNote | BareNoteWithoutExternalRefs, userInfo: AuthenticatedUser | null ) => { if (noteId == null) { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts index 89ba7907452e1..d9b770c258319 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.ts @@ -17,6 +17,7 @@ import type { AuthenticatedUser } from '@kbn/security-plugin/server'; import { UNAUTHENTICATED_USER } from '../../../../../common/constants'; import type { Note, + BareNote, PinnedEvent, AllTimelinesResponse, ExportTimelineNotFoundError, @@ -610,12 +611,15 @@ export const copyTimeline = async ( const copiedNotes = Promise.all( notes.map((_note) => { + const newNote: BareNote = { + ..._note, + timelineId: newTimelineId, + }; return note.persistNote({ request, noteId: null, - note: { - ..._note, - }, + note: newNote, + overrideOwner: false, }); }) ); From 7a5d5456e5a627e4d43e87cd5ba7e041e2b17216 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Wed, 26 Jun 2024 22:05:47 -0400 Subject: [PATCH 10/20] Undo unneeded test change --- .../server/lib/timeline/saved_object/timelines/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts index 82686c2a2307b..f8ce73092a804 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/timelines/index.test.ts @@ -540,7 +540,9 @@ describe('saved_object', () => { noteId: null, note: expect.objectContaining({ ...note, + timelineId: mockResolvedTimeline.savedObjectId, }), + overrideOwner: false, }) ); From 373af992629adac295a5fd2a6ffe7af631f5454d Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Thu, 27 Jun 2024 21:03:36 +0000 Subject: [PATCH 11/20] Add/update openapi schema --- .../delete_note/delete_note_route_schema.yaml | 18 ++++-- .../api/timeline/get_notes/get_notes_route.ts | 19 ++++++ .../get_notes/get_notes_route_schema.yaml | 60 +++++++++++++++++++ .../server/lib/timeline/routes/index.ts | 4 +- .../lib/timeline/routes/notes/get_notes.ts | 8 ++- .../server/lib/timeline/routes/notes/index.ts | 2 +- .../saved_object/notes/saved_object.ts | 6 +- 7 files changed, 105 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.ts create mode 100644 x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route_schema.yaml diff --git a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml index e05666a3319a8..0f39551e757b6 100644 --- a/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml +++ b/x-pack/plugins/security_solution/common/api/timeline/delete_note/delete_note_route_schema.yaml @@ -22,11 +22,19 @@ paths: content: application/json: schema: - type: object - properties: - noteId: - type: string - nullable: true + oneOf: + type: object + properties: + noteId: + type: string + nullable: true + type: object + properties: + noteIds: + type: array + items: + type: string + nullable: true responses: 200: description: Indicates the note was successfully deleted. \ No newline at end of file diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.ts b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.ts new file mode 100644 index 0000000000000..3b229c0432cc9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route.ts @@ -0,0 +1,19 @@ +/* + * 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 * as runtimeTypes from 'io-ts'; +import { unionWithNullType } from '../../../utility_types'; + +export const getNotesSchema = runtimeTypes.partial({ + alertIds: runtimeTypes.union([runtimeTypes.array(runtimeTypes.string), runtimeTypes.string]), + page: unionWithNullType(runtimeTypes.number), + perPage: unionWithNullType(runtimeTypes.number), + search: unionWithNullType(runtimeTypes.string), + sortField: unionWithNullType(runtimeTypes.string), + sortOrder: unionWithNullType(runtimeTypes.string), + filter: unionWithNullType(runtimeTypes.string), +}); diff --git a/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route_schema.yaml b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route_schema.yaml new file mode 100644 index 0000000000000..21d8a017c990c --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/timeline/get_notes/get_notes_route_schema.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.0 +info: + title: Elastic Security - Timeline - Notes API + version: 8.9.0 +servers: + - url: 'http://{kibana_host}:{port}' + variables: + kibana_host: + default: localhost + port: + default: '5601' +paths: + /api/note: + get: + operationId: getNotes + description: Gets notes + tags: + - access:securitySolution + parameters: + - name: alertIds + in: query + schema: + oneOf: + - type: array + items: + type: string + - type: string + - name: page + in: query + schema: + type: number + nullable: true + - name: perPage + in: query + schema: + type: number + nullable: true + - name: search + in: query + schema: + type: string + nullable: true + - name: sortField + in: query + schema: + type: string + nullable: true + - name: sortOrder + in: query + schema: + type: string + nullable: true + - name: filter + in: query + schema: + type: string + nullable: true + responses: + 200: + description: Indicates the requested notes were returned. diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts index 1a99a0cdac240..8fa7e7a8f31d6 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/index.ts @@ -24,7 +24,7 @@ import { getDraftTimelinesRoute } from './draft_timelines/get_draft_timelines'; import { cleanDraftTimelinesRoute } from './draft_timelines/clean_draft_timelines'; import { installPrepackedTimelinesRoute } from './prepackaged_timelines/install_prepackaged_timelines'; -import { persistNoteRoute, deleteNoteRoute, getNotesByDocumentIdsRoute } from './notes'; +import { persistNoteRoute, deleteNoteRoute, getNotesRoute } from './notes'; import { persistPinnedEventRoute } from './pinned_events'; @@ -51,7 +51,7 @@ export function registerTimelineRoutes( persistNoteRoute(router, config, security); deleteNoteRoute(router, config, security); - getNotesByDocumentIdsRoute(router, config, security); + getNotesRoute(router, config, security); persistPinnedEventRoute(router, config, security); } diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts index 81bef9dd1d521..3cb387a9b86ff 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/get_notes.ts @@ -14,10 +14,10 @@ import type { SetupPlugins } from '../../../../plugin'; import { buildSiemResponse } from '../../../detection_engine/routes/utils'; import { buildFrameworkRequest, getNotesPaginated } from '../../utils/common'; -import { getAllSavedNote } from '../../saved_object/notes'; +import { getAllSavedNote, MAX_UNASSOCIATED_NOTES } from '../../saved_object/notes'; import { noteSavedObjectType } from '../../saved_object_mappings/notes'; -export const getNotesByDocumentIdsRoute = ( +export const getNotesRoute = ( router: SecuritySolutionPluginRouter, _: ConfigType, security: SetupPlugins['security'] @@ -48,6 +48,8 @@ export const getNotesByDocumentIdsRoute = ( const options = { type: noteSavedObjectType, search: alertIdSearchString, + page: 1, + perPage: MAX_UNASSOCIATED_NOTES, }; const res = await getAllSavedNote(frameworkRequest, options); @@ -56,6 +58,8 @@ export const getNotesByDocumentIdsRoute = ( const options = { type: noteSavedObjectType, search: alertIds, + page: 1, + perPage: MAX_UNASSOCIATED_NOTES, }; const res = await getAllSavedNote(frameworkRequest, options); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts index c8a0ee8f2c841..1b83f1eb6b465 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/notes/index.ts @@ -7,4 +7,4 @@ export { persistNoteRoute } from './persist_note'; export { deleteNoteRoute } from './delete_note'; -export { getNotesByDocumentIdsRoute } from './get_notes'; +export { getNotesRoute } from './get_notes'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts index a6df441cbc0ac..ff277a4bea9e0 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object/notes/saved_object.ts @@ -30,6 +30,8 @@ import { noteSavedObjectType } from '../../saved_object_mappings/notes'; import { timelineSavedObjectType } from '../../saved_object_mappings'; import { noteFieldsMigrator } from './field_migrator'; +export const MAX_UNASSOCIATED_NOTES = 1000; + export const deleteNotesByTimelineId = async (request: FrameworkRequest, timelineId: string) => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, @@ -147,10 +149,10 @@ export const createNote = async ({ type: noteSavedObjectType, hasReference: { type: timelineSavedObjectType, id: '' }, }); - if (notesCount.total >= 1000) { + if (notesCount.total >= MAX_UNASSOCIATED_NOTES) { return { code: 403, - message: 'Cannot create more than 1000 notes without associating them to a timeline', + message: `Cannot create more than ${MAX_UNASSOCIATED_NOTES} notes without associating them to a timeline`, note: { ...note, noteId: uuidv1(), From 469f97f5fa5b2bf91ec1dcd4f6aefb0aeee1d953 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Fri, 28 Jun 2024 15:00:20 +0000 Subject: [PATCH 12/20] WIP using slice --- .../security_solution/public/notes/api/api.ts | 32 +++ .../security_solution/public/notes/index.ts | 9 + .../notes/pages/note_management_page.tsx | 215 +++++++++++++++++- .../public/notes/store/notes.slice.ts | 95 +++++++- .../edit_timeline_batch_actions.tsx | 2 +- .../components/open_timeline/index.tsx | 16 +- .../open_timeline/open_timeline.tsx | 159 +++++++------ .../timelines_table/actions_columns.tsx | 42 ++-- .../timelines_table/common_columns.tsx | 10 +- .../timelines_table/extended_columns.tsx | 4 +- .../timelines_table/icon_header_columns.tsx | 9 +- .../open_timeline/timelines_table/index.tsx | 92 ++++---- .../components/open_timeline/types.ts | 10 +- .../public/timelines/pages/index.tsx | 2 +- .../public/timelines/pages/timelines_page.tsx | 5 +- 15 files changed, 533 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/index.ts diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index a057289b5706a..511ce0ccfb145 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -30,6 +30,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; +}; + // TODO point to the correct API when it is available /** * Fetches all the notes for a document id diff --git a/x-pack/plugins/security_solution/public/notes/index.ts b/x-pack/plugins/security_solution/public/notes/index.ts new file mode 100644 index 0000000000000..7065eb76bb4fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/index.ts @@ -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 { NotesTable } from './pages/note_management_page'; +export * from './store/notes.slice'; diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 1964fa65fd96f..e0cd66aa7bdef 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -5,14 +5,217 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback, useMemo, useEffect } from 'react'; +import type { Criteria, DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiText, EuiBasicTable, EuiEmptyPrompt, EuiLoadingElastic, EuiButton } from '@elastic/eui'; +import { useDispatch, useSelector } from 'react-redux'; +import type { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; +// TODO unify this type from the api with the one in public/common/lib/note +import type { Note } from '../../../common/api/timeline'; +import { + userSelectedPage, + userSelectedPerPage, + userSortedNotes, + userFilteredNotes, + userSearchedNotes, + selectAllNotes, + selectNotesPagination, + selectNotesTableSort, + selectNotesTableTotalItems, +} from '..'; + +const columns: Array> = [ + { + field: 'created', + name: 'Last Edited', + sortable: true, + }, + { + field: 'createdBy', + name: 'Created by', + }, + { + field: 'eventId', + name: 'Document id', + sortable: true, + }, + { + field: 'timelineId', + name: 'Timeline id', + sortable: true, + }, + { + field: 'note', + name: 'Note', + }, +]; + +const pageSizeOptions = [50, 25, 10, 0]; + +const BulkNoteDeleteButton = ({ + selectedItems, + deleteSelectedNotes, +}: { + selectedItems: string[]; + deleteSelectedNotes: () => void; +}) => { + return selectedItems.length > 0 ? ( + + {`Delete ${selectedItems.length} Notes`} + + ) : null; +}; /** - * Page to allow users to manage notes. The page is accessible via the Investigations section within the Manage page. - * // TODO to be implemented + * */ -export const NoteManagementPage = () => { - return <>; +export const NotesTable = () => { + const dispatch = useDispatch(); + + const { index: page, size: perPage } = useSelector((state) => selectNotesPagination(state)); + + const onSelectionChange = useCallback( + (selectedItems: Note[]) => { + dispatch(appActions.notesTableSelectItems({ selectedItems })); + }, + [dispatch] + ); + + useEffect(() => { + dispatch(appActions.notesTableInitialize()); + }, [dispatch]); + + const selectedItems = useSelector((state) => appSelectors.selectNotesSelectedItems(state)); + + const selection: EuiTableSelectionType = useMemo(() => { + return { + onSelectionChange, + selectable: () => true, + }; + }, [onSelectionChange]); + + const onTableChange = useCallback( + ({ page, sort }: Criteria) => { + if (page && sort) { + dispatch(appActions.notesTableChange({ page, sort })); + } + }, + [dispatch] + ); + + const bulkDeleteNote = useCallback(() => { + if (selectedItems.length > 0) { + dispatch(appActions.bulkDeleteNotes({ noteIds: selectedItems })); + } + }, [dispatch, selectedItems]); + + const sorting = useSelector((state: State) => appSelectors.selectNotesTableSort(state)); + const deleteNote = useCallback( + (note: Note) => dispatch(appActions.deleteNoteRequest({ note })), + [dispatch] + ); + + const fetchLoading = useSelector((state: State) => + appSelectors.selectLoadingFetchByDocument(state) + ); + const fetchError = useSelector((state) => appSelectors.selectErrorFetchByDocument(state)); + + const totalItemCount = useSelector((state) => selectAllNotes(state).length); + + const notes = useSelector((state: State) => appSelectors.selectNotesTableCurrentPageItems(state)); + const startOfCurrentPage = pageIndex * pageSize + 1; + const endOfCurrentPage = Math.min((pageIndex + 1) * pageSize, totalItemCount); + + const resultsCount = + pageSize === 0 ? ( + {'All'} + ) : ( + <> + + {startOfCurrentPage} + {'-'} + {endOfCurrentPage} + + {' of '} {totalItemCount} + + ); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions, + }; + + const itemIdSelector = useCallback( + (item: Note) => { + return selectedItems.includes(item.noteId) ? item.noteId : item.noteId; + }, + [selectedItems] + ); + + const actions: Array> = [ + { + name: 'Delete', + description: 'Delete this note', + color: 'primary', + icon: 'trash', + type: 'icon', + onClick: (note: Note) => deleteNote(note), + }, + ]; + const columnWithActions = [ + ...columns, + { + name: 'actions', + actions, + }, + ]; + + if (fetchLoading) { + return ; + } + + if (fetchError) { + return ( + {'Unable to load your notes'}} + body={

{'No can do'}

} + /> + ); + } + + if (notes.length === 0) { + return ( + {'No notes'}} + body={

{'Add a note to get started'}

} + /> + ); + } + + return ( + <> + + {'Showing'} {resultsCount} {'Notes'} + + + + + ); }; -NoteManagementPage.displayName = 'NoteManagementPage'; +NotesTable.displayName = 'NotesTable'; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index b38f0983119e9..625110851f501 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -13,6 +13,7 @@ import { createNote as createNoteApi, deleteNote as deleteNoteApi, fetchNotesByDocumentId as fetchNotesByDocumentIdApi, + fetchNotes as fetchNotesApi, } from '../api/api'; import type { NormalizedEntities, NormalizedEntity } from './normalize'; import { normalizeEntities, normalizeEntity } from './normalize'; @@ -35,12 +36,26 @@ export interface NotesState extends EntityState { fetchNotesByDocumentId: ReqStatus; createNote: ReqStatus; deleteNote: ReqStatus; + fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentId: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; deleteNote: SerializedError | HttpError | null; + fetchNotes: SerializedError | HttpError | null; }; + pagination: { + page: number; + perPage: number; + total: number; + }; + sort: { + field: string; + order: string; + }; + filter: string; + search: string; + selectedIds: string[]; } const notesAdapter = createEntityAdapter({ @@ -52,12 +67,26 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ fetchNotesByDocumentId: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNote: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentId: null, createNote: null, deleteNote: null, + fetchNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 0, }, + sort: { + field: '@timestamp', + order: 'desc', + }, + filter: '', + search: '', + selectedIds: [], }); export const fetchNotesByDocumentId = createAsyncThunk< @@ -70,6 +99,22 @@ export const fetchNotesByDocumentId = createAsyncThunk< return normalizeEntities(res.notes); }); +export const fetchNotes = createAsyncThunk< + NormalizedEntities, + { + page: number; + perPage: number; + sortField: string; + sortOrder: string; + filter: string; + search: string; + }, + {} +>('notes/fetchNotes', async (args) => { + const res = await fetchNotesApi(args); + return normalizeEntities(res.notes); +}); + export const createNote = createAsyncThunk, { note: BareNote }, {}>( 'notes/createNote', async (args) => { @@ -91,7 +136,30 @@ export const deleteNote = createAsyncThunk( const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, - reducers: {}, + reducers: { + userSelectedPage: (state, action) => { + state.pagination.page = action.payload; + }, + userSelectedPerPage: (state, action) => { + state.pagination.perPage = action.payload; + }, + userSortedNotes: (state, action) => { + state.sort = action.payload; + }, + userFilteredNotes: (state, action) => { + state.filter = action.payload; + }, + userSearchedNotes: (state, action) => { + state.search = action.payload; + }, + userSelectedRow: (state, action) => { + if (state.selectedIds.includes(action.payload)) { + state.selectedIds.push(action.payload); + } else { + state.selectedIds = state.selectedIds.filter((id) => id !== action.payload); + } + }, + }, extraReducers(builder) { builder .addCase(fetchNotesByDocumentId.pending, (state) => { @@ -126,6 +194,17 @@ const notesSlice = createSlice({ .addCase(deleteNote.rejected, (state, action) => { state.status.deleteNote = ReqStatus.Failed; state.error.deleteNote = 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.status.fetchNotes = ReqStatus.Succeeded; + }) + .addCase(fetchNotes.rejected, (state, action) => { + state.status.fetchNotes = ReqStatus.Failed; + state.error.fetchNotes = action.payload ?? action.error; }); }, }); @@ -152,7 +231,21 @@ export const selectDeleteNoteStatus = (state: State) => state.notes.status.delet export const selectDeleteNoteError = (state: State) => state.notes.error.deleteNote; +export const selectNotesPagination = (state: State) => state.notes.pagination; + +export const selectNotesTableSort = (state: State) => state.notes.sort; + +export const selectNotesTableTotalItems = (state: State) => state.notes.pagination.total; + export const selectNotesByDocumentId = createSelector( [selectAllNotes, (state, documentId) => documentId], (notes, documentId) => notes.filter((note) => note.eventId === documentId) ); + +export const { + userSelectedPage, + userSelectedPerPage, + userSortedNotes, + userFilteredNotes, + userSearchedNotes, +} = notesSlice.actions; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 67d0c5a9e4599..073e9c486ac6d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -25,7 +25,7 @@ export const useEditTimelineBatchActions = ({ }: { deleteTimelines?: DeleteTimelines; selectedItems?: OpenTimelineResult[]; - tableRef: React.MutableRefObject | undefined>; + tableRef: React.MutableRefObject | null>; timelineType: TimelineType | null; }) => { const { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index aa80df5a33ba7..482bd6f127fb1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -305,12 +305,16 @@ export const StatefulOpenTimelineComponent = React.memo( /** 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 */ diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 28e42b3aa2020..fab38fa8a02ab 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -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 { NotesTable } from '../../../notes'; const QueryText = styled.span` white-space: normal; @@ -63,13 +64,13 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, + tabName, timelineType = TimelineType.default, timelineStatus, timelineFilter, templateTimelineFilter, totalSearchResultsCount, }) => { - const tableRef = useRef>(); const { actionItem, enableExportTimelineDownloader, @@ -78,7 +79,7 @@ export const OpenTimeline = React.memo( onOpenDeleteTimelineModal, onCompleteEditTimelineAction, } = useEditTimelineActions(); - + const tableRef = useRef | null>(null); const { kibanaSecuritySolutionsPrivileges } = useUserPrivileges(); const { getBatchItemsPopoverContent } = useEditTimelineBatchActions({ deleteTimelines: kibanaSecuritySolutionsPrivileges.crud ? deleteTimelines : undefined, @@ -227,84 +228,92 @@ export const OpenTimeline = React.memo(
{!!timelineFilter && timelineFilter} - - {SearchRowContent} - + {tabName !== 'notes' ? ( + <> + + {SearchRowContent} + - - - - - <> - {i18n.SHOWING}{' '} - {timelineType === TimelineType.template ? nTemplates : nTimelines} - - - - - {timelineStatus !== TimelineStatus.immutable && ( - <> - - {timelineType === TimelineType.template - ? i18n.SELECTED_TEMPLATES(selectedItems.length) - : i18n.SELECTED_TIMELINES(selectedItems.length)} + + + + + <> + {i18n.SHOWING}{' '} + {timelineType === TimelineType.template ? nTemplates : nTimelines} + + + + {timelineStatus !== TimelineStatus.immutable && ( + <> + + {timelineType === TimelineType.template + ? i18n.SELECTED_TEMPLATES(selectedItems.length) + : i18n.SELECTED_TIMELINES(selectedItems.length)} + + + + {i18n.BATCH_ACTIONS} + + + + )} - {i18n.BATCH_ACTIONS} + {i18n.REFRESH} - - )} - - {i18n.REFRESH} - - - - + + + - + + + ) : ( + + )}
); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index 9c58b30fbfc53..a5eab754a7290 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -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['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> => { 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', diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx index a040434f0a7df..7c1e0a419683e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/common_columns.tsx @@ -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; timelineType: TimelineType | null; -}) => [ +}): Array> => [ { + 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) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx index 454ecce7bf2af..3451d260da4f0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/extended_columns.tsx @@ -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']) => ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx index f43a713315d1b..412ccd72c815c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -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> => { const columns = { note: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'eventIdToNoteIds', name: ( @@ -40,7 +41,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, pinnedEvent: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'pinnedEventIds', name: ( @@ -57,7 +58,7 @@ export const getIconHeaderColumns = ({ width: ACTION_COLUMN_WIDTH, }, favorite: { - align: 'center', + align: 'center' as HorizontalAlignment, field: 'favorite', name: ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index b4841b68810f7..1e49028326b5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -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> => { 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 | undefined>; + tableRef: React.MutableRefObject | null>; totalSearchResultsCount: number; } @@ -157,33 +144,39 @@ export const TimelinesTable = React.memo( 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( : i18n.ZERO_TIMELINES_MATCH; return ( - ( 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} /> ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index 1373870c0b8aa..fd0fca18adc7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -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 ( diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 547bedf1caea3..459c37a4133f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -41,7 +41,7 @@ export const TimelinesPage = React.memo(() => { {indicesExist ? ( - {capabilitiesCanUserCRUD && ( + {capabilitiesCanUserCRUD && tabName !== 'notes' ? ( { - )} + ) : null} { setImportDataModalToggle={setImportDataModal} title={i18n.ALL_TIMELINES_PANEL_TITLE} data-test-subj="stateful-open-timeline" + tabName={tabName} /> ) : ( From 11490b43ef57759064c2d9586eb55211efcd2d89 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 05:06:07 +0000 Subject: [PATCH 13/20] WIP use rtk for management page --- .../public/notes/components/index.tsx | 9 + .../public/notes/components/search_row.tsx | 64 +++++ .../public/notes/components/utility_bar.tsx | 114 +++++++++ .../notes/pages/note_management_page.tsx | 229 +++++++++--------- .../components/open_timeline/index.tsx | 4 +- .../open_timeline/use_timeline_types.tsx | 35 ++- 6 files changed, 340 insertions(+), 115 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/components/index.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/search_row.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx diff --git a/x-pack/plugins/security_solution/public/notes/components/index.tsx b/x-pack/plugins/security_solution/public/notes/components/index.tsx new file mode 100644 index 0000000000000..a1cce4b7eb8ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/index.tsx @@ -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 { SearchRow } from './search_row'; +export { NotesUtilityBar } from './utility_bar'; diff --git a/x-pack/plugins/security_solution/public/notes/components/search_row.tsx b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx new file mode 100644 index 0000000000000..1e88a47b2e2d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/search_row.tsx @@ -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 ( + + + + + + + + ); +}); + +SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx new file mode 100644 index 0000000000000..885d050cc425a --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -0,0 +1,114 @@ +/* + * 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, EuiText } from '@elastic/eui'; +import { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../../common/components/utility_bar'; +import { + selectAllNotes, + selectNotesPagination, + selectNotesTableSort, + selectNotesTableTotalItems, + fetchNotes, + deleteNotes, + selectNotesTableSelectedIds, + selectNotesTableSearch, +} from '..'; + +export const NotesUtilityBar = React.memo(() => { + const dispatch = useDispatch(); + const notes = useSelector(selectAllNotes); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const totalItems = useSelector(selectNotesTableTotalItems); + 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(deleteNotes({ ids: selectedItems })); + }, [dispatch, selectedItems]); + const notesSearch = useSelector(selectNotesTableSearch); + + const BulkActionPopoverContent = useCallback( + (closePopover) => { + return ( +
+ +

{'Bulk actions'}

+
+ + {'Delete selected notes'} + +
+ ); + }, + [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 ( + + + + + {`Showing: ${resultsCount}`} + + + + + {selectedItems.length > 0 ? `${selectedItems.length} selected` : ''} + + + {'Bulk Actions'} + + + {`Refresh`} + + + + + ); +}); + +NotesUtilityBar.displayName = 'NotesUtilityBar'; diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index e0cd66aa7bdef..b6a60f68e2cce 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -6,23 +6,28 @@ */ import React, { useCallback, useMemo, useEffect } from 'react'; -import type { Criteria, DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; -import { EuiText, EuiBasicTable, EuiEmptyPrompt, EuiLoadingElastic, EuiButton } from '@elastic/eui'; +import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; +import { EuiBasicTable, EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { useDispatch, useSelector } from 'react-redux'; -import type { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; // TODO unify this type from the api with the one in public/common/lib/note import type { Note } from '../../../common/api/timeline'; import { userSelectedPage, userSelectedPerPage, + userSelectedRow, userSortedNotes, - userFilteredNotes, - userSearchedNotes, selectAllNotes, selectNotesPagination, selectNotesTableSort, selectNotesTableTotalItems, + fetchNotes, + deleteNote, + deleteNotes, + selectNotesTableSelectedIds, + selectNotesTableSearch, } from '..'; +import { SearchRow } from '../components/search_row'; +import { NotesUtilityBar } from '../components/utility_bar'; const columns: Array> = [ { @@ -42,7 +47,6 @@ const columns: Array> = [ { field: 'timelineId', name: 'Timeline id', - sortable: true, }, { field: 'note', @@ -71,121 +75,122 @@ const BulkNoteDeleteButton = ({ */ export const NotesTable = () => { const dispatch = useDispatch(); - - const { index: page, size: perPage } = useSelector((state) => selectNotesPagination(state)); - - const onSelectionChange = useCallback( - (selectedItems: Note[]) => { - dispatch(appActions.notesTableSelectItems({ selectedItems })); - }, - [dispatch] - ); + const notes = useSelector(selectAllNotes); + const pagination = useSelector(selectNotesPagination); + const sort = useSelector(selectNotesTableSort); + const totalItems = useSelector(selectNotesTableTotalItems); + const selectedItems = useSelector(selectNotesTableSelectedIds); + const notesSearch = useSelector(selectNotesTableSearch); + + 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(() => { - dispatch(appActions.notesTableInitialize()); - }, [dispatch]); - - const selectedItems = useSelector((state) => appSelectors.selectNotesSelectedItems(state)); - - const selection: EuiTableSelectionType = useMemo(() => { - return { - onSelectionChange, - selectable: () => true, - }; - }, [onSelectionChange]); + fetchData(); + }, [fetchData]); const onTableChange = useCallback( - ({ page, sort }: Criteria) => { - if (page && sort) { - dispatch(appActions.notesTableChange({ page, sort })); + ({ + page, + sort: newSort, + }: { + page?: { index: number; size: number }; + sort?: { field: string; direction: string }; + }) => { + if (page) { + dispatch(userSelectedPage(page.index + 1)); + dispatch(userSelectedPerPage(page.size)); + } + if (newSort) { + dispatch(userSortedNotes({ field: newSort.field, order: newSort.direction })); } }, [dispatch] ); - const bulkDeleteNote = useCallback(() => { - if (selectedItems.length > 0) { - dispatch(appActions.bulkDeleteNotes({ noteIds: selectedItems })); - } - }, [dispatch, selectedItems]); - - const sorting = useSelector((state: State) => appSelectors.selectNotesTableSort(state)); - const deleteNote = useCallback( - (note: Note) => dispatch(appActions.deleteNoteRequest({ note })), + const onDeleteNote = useCallback( + (id: string) => { + dispatch(deleteNote({ id })); + }, [dispatch] ); - const fetchLoading = useSelector((state: State) => - appSelectors.selectLoadingFetchByDocument(state) - ); - const fetchError = useSelector((state) => appSelectors.selectErrorFetchByDocument(state)); - - const totalItemCount = useSelector((state) => selectAllNotes(state).length); - - const notes = useSelector((state: State) => appSelectors.selectNotesTableCurrentPageItems(state)); - const startOfCurrentPage = pageIndex * pageSize + 1; - const endOfCurrentPage = Math.min((pageIndex + 1) * pageSize, totalItemCount); - - const resultsCount = - pageSize === 0 ? ( - {'All'} - ) : ( - <> - - {startOfCurrentPage} - {'-'} - {endOfCurrentPage} - - {' of '} {totalItemCount} - - ); - - const pagination = { - pageIndex, - pageSize, - totalItemCount, - pageSizeOptions, - }; - - const itemIdSelector = useCallback( - (item: Note) => { - return selectedItems.includes(item.noteId) ? item.noteId : item.noteId; + const onSelectionChange = useCallback( + (selection: Note[]) => { + const rowIds = selection.map((item) => item.noteId); + dispatch(userSelectedRow(rowIds)); }, - [selectedItems] + [dispatch] ); - const actions: Array> = [ - { - name: 'Delete', - description: 'Delete this note', - color: 'primary', - icon: 'trash', - type: 'icon', - onClick: (note: Note) => deleteNote(note), - }, - ]; - const columnWithActions = [ - ...columns, - { - name: 'actions', - actions, - }, - ]; + const itemIdSelector = useCallback((item: Note) => { + return item.noteId; + }, []); - if (fetchLoading) { - return ; - } + const deleteSelectedNotes = useCallback(() => { + dispatch(deleteNotes({ ids: selectedItems })); + }, [dispatch, selectedItems]); - if (fetchError) { - return ( - {'Unable to load your notes'}} - body={

{'No can do'}

} - /> - ); - } + const columnWithActions = useMemo(() => { + const actions: Array> = [ + { + name: 'Delete', + description: 'Delete this note', + color: 'primary', + icon: 'trash', + type: 'icon', + onClick: (note: Note) => deleteNote({ id: note.noteId }), + }, + ]; + return [ + ...columns, + { + name: 'actions', + actions, + }, + ]; + }, []); + + // if (fetchLoading) { + // return ; + // } + + // if (fetchError) { + // return ( + // {'Unable to load your notes'}} + // body={

{'No can do'}

} + // /> + // ); + // } + + const currentPagination = useMemo(() => { + return { + pageIndex: pagination.page - 1, + pageSize: pagination.perPage, + totalItemCount: totalItems, + pageSizeOptions, + }; + }, [pagination, totalItems]); + + const selection = useMemo(() => { + return { + onSelectionChange, + selectable: () => true, + }; + }, [onSelectionChange]); if (notes.length === 0) { return ( @@ -199,19 +204,19 @@ export const NotesTable = () => { return ( <> - - {'Showing'} {resultsCount} {'Notes'} - - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 482bd6f127fb1..11e35ce4a800a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -69,7 +69,7 @@ interface OwnProps { 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( importDataModalToggle, onOpenTimeline, setImportDataModalToggle, + tabName, title, }) => { const dispatch = useDispatch(); @@ -418,6 +419,7 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} + tabName={tabName} templateTimelineFilter={templateTimelineFilter} timelineType={timelineType} timelineStatus={timelineStatus} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 5eefa23b0750a..d8943b0f674e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -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} ))} + {notesEnabled && ( + + {'Notes'} + + )} From 9a65498a4714807bd6b5dad7bd312eec74bb89c1 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 06:35:56 +0000 Subject: [PATCH 14/20] Notes management table and tab --- .../notes/components/delete_confirm_modal.tsx | 52 +++++++++ .../public/notes/components/index.tsx | 1 + .../public/notes/components/translations.ts | 108 ++++++++++++++++++ .../public/notes/components/utility_bar.tsx | 20 ++-- .../notes/pages/note_management_page.tsx | 107 ++++++++--------- .../public/notes/store/notes.slice.ts | 41 ++++++- 6 files changed, 258 insertions(+), 71 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx create mode 100644 x-pack/plugins/security_solution/public/notes/components/translations.ts diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx new file mode 100644 index 0000000000000..d7cf22e1ed12f --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx @@ -0,0 +1,52 @@ +/* + * 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 onCancel = useCallback(() => { + dispatch(userClosedDeleteModal()); + }, [dispatch]); + + const onConfirm = useCallback(() => { + dispatch(deleteNotes({ ids: pendingDeleteIds })); + }, [dispatch, pendingDeleteIds]); + + const deleteNotesStatus = useSelector(selectDeleteNotesStatus); + + const isLoading = deleteNotesStatus === ReqStatus.Loading; + + return ( + + {i18n.DELETE_NOTES_CONFIRM(pendingDeleteIds.length)} + + ); +}); + +DeleteConfirmModal.displayName = 'DeleteConfirmModal'; diff --git a/x-pack/plugins/security_solution/public/notes/components/index.tsx b/x-pack/plugins/security_solution/public/notes/components/index.tsx index a1cce4b7eb8ac..88186e74cc418 100644 --- a/x-pack/plugins/security_solution/public/notes/components/index.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/index.tsx @@ -7,3 +7,4 @@ export { SearchRow } from './search_row'; export { NotesUtilityBar } from './utility_bar'; +export { DeleteConfirmModal } from './delete_confirm_modal'; diff --git a/x-pack/plugins/security_solution/public/notes/components/translations.ts b/x-pack/plugins/security_solution/public/notes/components/translations.ts new file mode 100644 index 0000000000000..3f47cc0f2802a --- /dev/null +++ b/x-pack/plugins/security_solution/public/notes/components/translations.ts @@ -0,0 +1,108 @@ +/* + * 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 TABLE_ERROR = i18n.translate('xpack.securitySolution.notes.management.tableError', { + defaultMessage: 'Unable to load notes', +}); + +export const TABLE_EMPTY = i18n.translate('xpack.securitySolution.notes.management.tableEmpty', { + defaultMessage: 'No notes found', +}); + +export const TABLE_EMPTY_HELP = i18n.translate( + 'xpack.securitySolution.notes.management.tableEmptyHelp', + { + defaultMessage: 'Add a note to get started', + } +); + +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, =1 {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', +}); diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index 885d050cc425a..ff08175fb9d56 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -6,7 +6,7 @@ */ import React, { useMemo, useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { EuiContextMenuItem, EuiText } from '@elastic/eui'; +import { EuiContextMenuItem } from '@elastic/eui'; import { UtilityBarGroup, UtilityBarText, @@ -15,19 +15,18 @@ import { UtilityBarAction, } from '../../common/components/utility_bar'; import { - selectAllNotes, selectNotesPagination, selectNotesTableSort, selectNotesTableTotalItems, fetchNotes, - deleteNotes, selectNotesTableSelectedIds, selectNotesTableSearch, + userSelectedBulkDelete, } from '..'; +import * as i18n from './translations'; export const NotesUtilityBar = React.memo(() => { const dispatch = useDispatch(); - const notes = useSelector(selectAllNotes); const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const totalItems = useSelector(selectNotesTableTotalItems); @@ -39,17 +38,14 @@ export const NotesUtilityBar = React.memo(() => { return perPage === 0 ? 'All' : `${startOfCurrentPage}-${endOfCurrentPage} of ${totalItems}`; }, [pagination, totalItems]); const deleteSelectedNotes = useCallback(() => { - dispatch(deleteNotes({ ids: selectedItems })); - }, [dispatch, selectedItems]); + dispatch(userSelectedBulkDelete()); + }, [dispatch]); const notesSearch = useSelector(selectNotesTableSearch); const BulkActionPopoverContent = useCallback( (closePopover) => { return (
- -

{'Bulk actions'}

-
{ icon="trash" key="DeleteItemKey" > - {'Delete selected notes'} + {i18n.DELETE_SELECTED}
); @@ -95,7 +91,7 @@ export const NotesUtilityBar = React.memo(() => { popoverContent={BulkActionPopoverContent} data-test-subj="utility-bar-action" > - {'Bulk Actions'} + {i18n.BATCH_ACTIONS} { iconType="refresh" onClick={refresh} > - {`Refresh`} + {i18n.REFRESH} diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index b6a60f68e2cce..21a43c1b7e6aa 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -7,10 +7,11 @@ import React, { useCallback, useMemo, useEffect } from 'react'; import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBasicTable, EuiEmptyPrompt, EuiButton } 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, @@ -21,55 +22,46 @@ import { selectNotesTableSort, selectNotesTableTotalItems, fetchNotes, - deleteNote, - deleteNotes, - selectNotesTableSelectedIds, selectNotesTableSearch, + selectFetchNotesStatus, + selectNotesTablePendingDeleteIds, + userSelectedRowForDeletion, + selectFetchNotesError, + ReqStatus, } 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> = [ { field: 'created', - name: 'Last Edited', + name: i18n.CREATED_COLUMN, sortable: true, + render: (created: Note['created']) => , }, { field: 'createdBy', - name: 'Created by', + name: i18n.CREATED_BY_COLUMN, }, { field: 'eventId', - name: 'Document id', + name: i18n.EVENT_ID_COLUMN, sortable: true, }, { field: 'timelineId', - name: 'Timeline id', + name: i18n.TIMELINE_ID_COLUMN, }, { field: 'note', - name: 'Note', + name: i18n.NOTE_CONTENT_COLUMN, }, ]; const pageSizeOptions = [50, 25, 10, 0]; -const BulkNoteDeleteButton = ({ - selectedItems, - deleteSelectedNotes, -}: { - selectedItems: string[]; - deleteSelectedNotes: () => void; -}) => { - return selectedItems.length > 0 ? ( - - {`Delete ${selectedItems.length} Notes`} - - ) : null; -}; - /** * */ @@ -79,8 +71,13 @@ export const NotesTable = () => { const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); const totalItems = useSelector(selectNotesTableTotalItems); - const selectedItems = useSelector(selectNotesTableSelectedIds); 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( @@ -118,9 +115,9 @@ export const NotesTable = () => { [dispatch] ); - const onDeleteNote = useCallback( - (id: string) => { - dispatch(deleteNote({ id })); + const selectRowForDeletion = useCallback( + (ids) => { + dispatch(userSelectedRowForDeletion(ids)); }, [dispatch] ); @@ -137,19 +134,15 @@ export const NotesTable = () => { return item.noteId; }, []); - const deleteSelectedNotes = useCallback(() => { - dispatch(deleteNotes({ ids: selectedItems })); - }, [dispatch, selectedItems]); - const columnWithActions = useMemo(() => { const actions: Array> = [ { - name: 'Delete', - description: 'Delete this note', + name: i18n.DELETE, + description: i18n.DELETE_SINGLE_NOTE_DESCRIPTION, color: 'primary', icon: 'trash', type: 'icon', - onClick: (note: Note) => deleteNote({ id: note.noteId }), + onClick: (note: Note) => selectRowForDeletion(note.noteId), }, ]; return [ @@ -159,22 +152,7 @@ export const NotesTable = () => { actions, }, ]; - }, []); - - // if (fetchLoading) { - // return ; - // } - - // if (fetchError) { - // return ( - // {'Unable to load your notes'}} - // body={

{'No can do'}

} - // /> - // ); - // } + }, [selectRowForDeletion]); const currentPagination = useMemo(() => { return { @@ -192,12 +170,29 @@ export const NotesTable = () => { }; }, [onSelectionChange]); + const sorting: { sort: { field: keyof Note; direction: 'asc' | 'desc' } } = useMemo(() => { + return { + sort, + }; + }, [sort]); + + if (fetchError) { + return ( + {i18n.TABLE_ERROR}} + body={

{fetchErrorData}

} + /> + ); + } + if (notes.length === 0) { return ( {'No notes'}} - body={

{'Add a note to get started'}

} + title={

{i18n.TABLE_EMPTY}

} + body={

{i18n.TABLE_EMPTY_HELP}

} /> ); } @@ -206,19 +201,17 @@ export const NotesTable = () => { <> - + {isDeleteModalVisible && } ); }; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 4246cc8fe2d96..d392930a4b01f 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -38,12 +38,14 @@ export interface NotesState extends EntityState { createNote: ReqStatus; deleteNote: ReqStatus; fetchNotes: ReqStatus; + deleteNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; deleteNote: SerializedError | HttpError | null; fetchNotes: SerializedError | HttpError | null; + deleteNotes: SerializedError | HttpError | null; }; pagination: { page: number; @@ -51,12 +53,13 @@ export interface NotesState extends EntityState { total: number; }; sort: { - field: string; - direction: string; + field: keyof Note; + direction: 'asc' | 'desc'; }; filter: string; search: string; selectedIds: string[]; + pendingDeleteIds: string[]; } const notesAdapter = createEntityAdapter({ @@ -69,11 +72,13 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ createNote: ReqStatus.Idle, deleteNote: ReqStatus.Idle, fetchNotes: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null, + deleteNotes: null, fetchNotes: null, }, pagination: { @@ -88,6 +93,7 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ filter: '', search: '', selectedIds: [], + pendingDeleteIds: [], }); export const fetchNotesByDocumentIds = createAsyncThunk< @@ -166,6 +172,15 @@ const notesSlice = createSlice({ userSelectedRow: (state, action) => { state.selectedIds = action.payload; }, + userClosedDeleteModal: (state) => { + state.pendingDeleteIds = []; + }, + userSelectedRowForDeletion: (state, action) => { + state.pendingDeleteIds = [action.payload]; + }, + userSelectedBulkDelete: (state) => { + state.pendingDeleteIds = state.selectedIds; + }, }, extraReducers(builder) { builder @@ -202,6 +217,17 @@ const notesSlice = createSlice({ state.status.deleteNote = ReqStatus.Failed; state.error.deleteNote = action.payload ?? action.error; }) + .addCase(deleteNotes.pending, (state, action) => { + state.status.deleteNotes = ReqStatus.Loading; + }) + .addCase(deleteNotes.fulfilled, (state, action) => { + notesAdapter.removeMany(state, action.payload); + state.status.deleteNotes = ReqStatus.Succeeded; + }) + .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; }) @@ -249,6 +275,14 @@ export const selectNotesTableSelectedIds = (state: State) => state.notes.selecte export const selectNotesTableSearch = (state: State) => state.notes.search; +export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; + +export const selectDeleteNotesStatus = (state: State) => state.notes.status.deleteNotes; + +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) @@ -261,4 +295,7 @@ export const { userFilteredNotes, userSearchedNotes, userSelectedRow, + userClosedDeleteModal, + userSelectedRowForDeletion, + userSelectedBulkDelete, } = notesSlice.actions; From 0b5e41e46bb425647167cebd011d72eb34959377 Mon Sep 17 00:00:00 2001 From: PhilippeOberti Date: Mon, 1 Jul 2024 12:27:24 +0200 Subject: [PATCH 15/20] - add page title - remove ids from pendingDeleteIds on delete success - remove no notes empty prompts and related translations - remove unnecessary index file - change data-test-subj to be more specific - merged deleteNote and deleteNotes - added unit tests --- .../public/common/mock/global_state.ts | 23 +- .../left/components/notes_list.test.tsx | 6 +- .../left/components/notes_list.tsx | 12 +- .../security_solution/public/notes/api/api.ts | 11 - .../notes/components/delete_confirm_modal.tsx | 8 +- .../public/notes/components/index.tsx | 10 - .../public/notes/components/translations.ts | 20 +- .../public/notes/components/utility_bar.tsx | 44 +-- .../security_solution/public/notes/index.ts | 2 +- .../notes/pages/note_management_page.tsx | 29 +- .../public/notes/store/notes.slice.test.ts | 356 ++++++++++++------ .../public/notes/store/notes.slice.ts | 59 +-- 12 files changed, 326 insertions(+), 254 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/notes/components/index.tsx diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index 6aa38d25806a8..0a8aebee35f55 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -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: [], }, }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx index 8491804e1a572..ec80806d4f718 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx @@ -196,7 +196,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Loading, + deleteNotes: ReqStatus.Loading, }, }, }); @@ -217,11 +217,11 @@ describe('NotesList', () => { ...mockGlobalState.notes, status: { ...mockGlobalState.notes.status, - deleteNote: ReqStatus.Failed, + deleteNotes: ReqStatus.Failed, }, error: { ...mockGlobalState.notes.error, - deleteNote: { type: 'http', status: 500 }, + deleteNotes: { type: 'http', status: 500 }, }, }, }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx index c27f8441c103a..51ee119499fd1 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.tsx @@ -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] ); diff --git a/x-pack/plugins/security_solution/public/notes/api/api.ts b/x-pack/plugins/security_solution/public/notes/api/api.ts index 19f2d8506ae48..4c9542458c304 100644 --- a/x-pack/plugins/security_solution/public/notes/api/api.ts +++ b/x-pack/plugins/security_solution/public/notes/api/api.ts @@ -75,17 +75,6 @@ export const fetchNotesByDocumentIds = async (documentIds: string[]) => { return response; }; -/** - * Deletes a note - */ -export const deleteNote = async (noteId: string) => { - const response = await KibanaServices.get().http.delete<{ data: unknown }>(NOTE_URL, { - body: JSON.stringify({ noteId }), - version: '2023-10-31', - }); - return response; -}; - /** * Deletes multiple notes */ diff --git a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx index d7cf22e1ed12f..e4a37d6594e14 100644 --- a/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/delete_confirm_modal.tsx @@ -19,6 +19,8 @@ import { 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()); @@ -28,17 +30,13 @@ export const DeleteConfirmModal = React.memo(() => { dispatch(deleteNotes({ ids: pendingDeleteIds })); }, [dispatch, pendingDeleteIds]); - const deleteNotesStatus = useSelector(selectDeleteNotesStatus); - - const isLoading = deleteNotesStatus === ReqStatus.Loading; - return ( i18n.translate('xpack.securitySolution.notes.management.deleteNotesConfirm', { values: { selectedNotes }, defaultMessage: - 'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, =1 {note} other {notes}?}', + 'Are you sure you want to delete {selectedNotes} {selectedNotes, plural, one {note} other {notes}}?', }); export const DELETE_NOTES_CANCEL = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index ff08175fb9d56..65e64ac37dff2 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -42,24 +42,19 @@ export const NotesUtilityBar = React.memo(() => { }, [dispatch]); const notesSearch = useSelector(selectNotesTableSearch); - const BulkActionPopoverContent = useCallback( - (closePopover) => { - return ( -
- - {i18n.DELETE_SELECTED} - -
- ); - }, - [deleteSelectedNotes, selectedItems.length] - ); + const BulkActionPopoverContent = useCallback(() => { + return ( + + {i18n.DELETE_SELECTED} + + ); + }, [deleteSelectedNotes, selectedItems.length]); const refresh = useCallback(() => { dispatch( fetchNotes({ @@ -76,25 +71,26 @@ export const NotesUtilityBar = React.memo(() => { - + {`Showing: ${resultsCount}`} - + {selectedItems.length > 0 ? `${selectedItems.length} selected` : ''} - {i18n.BATCH_ACTIONS} + + {i18n.BATCH_ACTIONS} + > = [ { @@ -63,9 +64,11 @@ const columns: Array> = [ const pageSizeOptions = [50, 25, 10, 0]; /** - * + * 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 NotesTable = () => { +export const NoteManagementPage = () => { const dispatch = useDispatch(); const notes = useSelector(selectAllNotes); const pagination = useSelector(selectNotesPagination); @@ -116,8 +119,8 @@ export const NotesTable = () => { ); const selectRowForDeletion = useCallback( - (ids) => { - dispatch(userSelectedRowForDeletion(ids)); + (id: string) => { + dispatch(userSelectedRowForDeletion(id)); }, [dispatch] ); @@ -187,18 +190,12 @@ export const NotesTable = () => { ); } - if (notes.length === 0) { - return ( - {i18n.TABLE_EMPTY}} - body={

{i18n.TABLE_EMPTY_HELP}

} - /> - ); - } - return ( <> + +

{NOTES_MANAGEMENT_TITLE}

+
+ { ); }; -NotesTable.displayName = 'NotesTable'; +NoteManagementPage.displayName = 'NoteManagementPage'; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 59f196b6a5af5..6bb9deafc18c6 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -4,10 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import * as uuid from 'uuid'; import { createNote, - deleteNote, + deleteNotes, fetchNotesByDocumentIds, initialNotesState, notesReducer, @@ -15,103 +14,117 @@ import { selectAllNotes, selectCreateNoteError, selectCreateNoteStatus, - selectDeleteNoteError, - selectDeleteNoteStatus, + selectDeleteNotesError, + selectDeleteNotesStatus, selectFetchNotesByDocumentIdsError, selectFetchNotesByDocumentIdsStatus, selectNoteById, selectNoteIds, selectNotesByDocumentId, + userClosedDeleteModal, + userFilteredNotes, + userSearchedNotes, + userSelectedBulkDelete, + userSelectedPage, + userSelectedPerPage, + userSelectedRow, + userSelectedRowForDeletion, + userSortedNotes, } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; +import type { Note } from '../../../common/api/timeline'; +import * as uuid from 'uuid'; 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 mockNote = { ...generateNoteMock(['1'])[0] }; +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 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 +138,270 @@ describe('notesSlice', () => { }; expect(notesReducer(initialNonEmptyState, action)).toEqual({ - entities: action.payload.entities.notes, - ids: action.payload.result, + ...initalEmptyState, + entities: { + [newMockNote.noteId]: newMockNote, + [mockNote2.noteId]: mockNote2, + }, + ids: [newMockNote.noteId, mockNote2.noteId], status: { + ...initalEmptyState.status, fetchNotesByDocumentIds: ReqStatus.Succeeded, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, }, - 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: [], + expect(notesReducer(state, action)).toEqual({ + ...initialNonEmptyState, + entities: { + [mockNote2.noteId]: mockNote2, + }, + ids: [mockNote2.noteId], status: { - fetchNotesByDocumentIds: ReqStatus.Idle, - createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Succeeded, + ...initialNonEmptyState.status, + deleteNotes: ReqStatus.Succeeded, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + 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', + }, + }); + }); + }); + + 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 +421,21 @@ 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); }); }); }); diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index d392930a4b01f..b36b3b578eb74 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -11,7 +11,6 @@ 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, @@ -36,16 +35,14 @@ export interface NotesState extends EntityState { status: { fetchNotesByDocumentIds: ReqStatus; createNote: ReqStatus; - deleteNote: ReqStatus; - fetchNotes: ReqStatus; deleteNotes: ReqStatus; + fetchNotes: ReqStatus; }; error: { fetchNotesByDocumentIds: SerializedError | HttpError | null; createNote: SerializedError | HttpError | null; - deleteNote: SerializedError | HttpError | null; - fetchNotes: SerializedError | HttpError | null; deleteNotes: SerializedError | HttpError | null; + fetchNotes: SerializedError | HttpError | null; }; pagination: { page: number; @@ -70,14 +67,12 @@ export const initialNotesState: NotesState = notesAdapter.getInitialState({ status: { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, - deleteNote: ReqStatus.Idle, - fetchNotes: ReqStatus.Idle, deleteNotes: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, }, error: { fetchNotesByDocumentIds: null, createNote: null, - deleteNote: null, deleteNotes: null, fetchNotes: null, }, @@ -132,15 +127,6 @@ export const createNote = createAsyncThunk, { note: BareN } ); -export const deleteNote = createAsyncThunk( - 'notes/deleteNote', - async (args) => { - const { id } = args; - await deleteNoteApi(id); - return id; - } -); - export const deleteNotes = createAsyncThunk( 'notes/deleteNotes', async (args) => { @@ -154,28 +140,31 @@ const notesSlice = createSlice({ name: 'notes', initialState: initialNotesState, reducers: { - userSelectedPage: (state, action) => { + userSelectedPage: (state, action: { payload: number }) => { state.pagination.page = action.payload; }, - userSelectedPerPage: (state, action) => { + userSelectedPerPage: (state, action: { payload: number }) => { state.pagination.perPage = action.payload; }, - userSortedNotes: (state, action) => { + userSortedNotes: ( + state, + action: { payload: { field: keyof Note; direction: 'asc' | 'desc' } } + ) => { state.sort = action.payload; }, - userFilteredNotes: (state, action) => { + userFilteredNotes: (state, action: { payload: string }) => { state.filter = action.payload; }, - userSearchedNotes: (state, action) => { + userSearchedNotes: (state, action: { payload: string }) => { state.search = action.payload; }, - userSelectedRow: (state, action) => { + userSelectedRow: (state, action: { payload: string[] }) => { state.selectedIds = action.payload; }, userClosedDeleteModal: (state) => { state.pendingDeleteIds = []; }, - userSelectedRowForDeletion: (state, action) => { + userSelectedRowForDeletion: (state, action: { payload: string }) => { state.pendingDeleteIds = [action.payload]; }, userSelectedBulkDelete: (state) => { @@ -206,23 +195,15 @@ 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(deleteNote.fulfilled, (state, action) => { - notesAdapter.removeOne(state, action.payload); - state.status.deleteNote = ReqStatus.Succeeded; - }) - .addCase(deleteNote.rejected, (state, action) => { - state.status.deleteNote = ReqStatus.Failed; - state.error.deleteNote = action.payload ?? action.error; - }) - .addCase(deleteNotes.pending, (state, action) => { + .addCase(deleteNotes.pending, (state) => { state.status.deleteNotes = ReqStatus.Loading; }) .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(deleteNotes.rejected, (state, action) => { state.status.deleteNotes = ReqStatus.Failed; @@ -261,9 +242,9 @@ 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; @@ -277,8 +258,6 @@ export const selectNotesTableSearch = (state: State) => state.notes.search; export const selectNotesTablePendingDeleteIds = (state: State) => state.notes.pendingDeleteIds; -export const selectDeleteNotesStatus = (state: State) => state.notes.status.deleteNotes; - export const selectFetchNotesError = (state: State) => state.notes.error.fetchNotes; export const selectFetchNotesStatus = (state: State) => state.notes.status.fetchNotes; From 3e29f138643713e00ad9393f44247c05e413f028 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 13:00:01 +0000 Subject: [PATCH 16/20] Add tests --- .../public/notes/store/notes.slice.test.ts | 192 +++++++++++++++++- .../public/notes/store/notes.slice.ts | 1 + 2 files changed, 191 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 59f196b6a5af5..d8add90a886e9 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -5,6 +5,8 @@ * 2.0. */ import * as uuid from 'uuid'; +import { miniSerializeError } from '@reduxjs/toolkit'; +import type { SerializedError } from '@reduxjs/toolkit'; import { createNote, deleteNote, @@ -22,7 +24,19 @@ import { selectNoteById, selectNoteIds, selectNotesByDocumentId, + selectNotesPagination, + selectNotesTablePendingDeleteIds, + selectNotesTableSearch, + selectNotesTableSelectedIds, + selectNotesTableSort, + selectNotesTableTotalItems, + selectFetchNotesStatus, + selectFetchNotesError, + selectDeleteNotesStatus, + deleteNotes, + fetchNotes, } from './notes.slice'; +import type { NotesState } from './notes.slice'; import { mockGlobalState } from '../../common/mock'; const initalEmptyState = initialNotesState; @@ -41,7 +55,7 @@ export const generateNoteMock = (documentIds: string[]) => })); const mockNote = { ...generateNoteMock(['1'])[0] }; -const initialNonEmptyState = { +const initialNonEmptyState: NotesState = { entities: { [mockNote.noteId]: mockNote, }, @@ -50,8 +64,29 @@ const initialNonEmptyState = { fetchNotesByDocumentIds: ReqStatus.Idle, createNote: ReqStatus.Idle, deleteNote: ReqStatus.Idle, + fetchNotes: ReqStatus.Idle, + deleteNotes: ReqStatus.Idle, }, - error: { fetchNotesByDocumentIds: null, createNote: null, deleteNote: null }, + error: { + fetchNotesByDocumentIds: null, + createNote: null, + deleteNote: null, + fetchNotes: null, + deleteNotes: null, + }, + pagination: { + page: 1, + perPage: 10, + total: 1, + }, + sort: { + field: 'created', + direction: 'desc', + }, + filter: '', + search: '', + selectedIds: [], + pendingDeleteIds: [], }; describe('notesSlice', () => { @@ -269,6 +304,70 @@ describe('notesSlice', () => { }, }); }); + + 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: { [mockNote.noteId]: mockNote, '2': { ...mockNote, 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': mockNote, '2': { ...mockNote, 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' }); + }); }); }); @@ -325,5 +424,94 @@ describe('notesSlice', () => { it('should return no notes if document id does not exist', () => { expect(selectNotesByDocumentId(mockGlobalState, '2')).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(selectNotesTableTotalItems(state)).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); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index d392930a4b01f..fc5fc4496ae98 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -235,6 +235,7 @@ const notesSlice = createSlice({ 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; From e60aa4de1d725b28aec29a638c34f8bd4627e61c Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 14:16:15 +0000 Subject: [PATCH 17/20] Move notes page to timelines, register deep link conditionally --- .../public/management/links.ts | 20 ------------------- .../notes/pages/note_management_page.tsx | 7 +------ .../open_timeline/open_timeline.tsx | 4 ++-- .../public/timelines/links.ts | 13 +++++++++++- 4 files changed, 15 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/links.ts b/x-pack/plugins/security_solution/public/management/links.ts index 06d47e2936115..91bf4e958f6fb 100644 --- a/x-pack/plugins/security_solution/public/management/links.ts +++ b/x-pack/plugins/security_solution/public/management/links.ts @@ -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', - }, ], }; diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 0f959b7cc7e1e..2370c1d6d4d21 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useMemo, useEffect } from 'react'; import type { DefaultItemAction, EuiBasicTableColumn } from '@elastic/eui'; -import { EuiBasicTable, EuiEmptyPrompt, EuiSpacer, EuiTitle } 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'; @@ -34,7 +34,6 @@ 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'; -import { NOTES_MANAGEMENT_TITLE } from '../components/translations'; const columns: Array> = [ { @@ -193,10 +192,6 @@ export const NoteManagementPage = () => { return ( <> - -

{NOTES_MANAGEMENT_TITLE}

-
- ( /> ) : ( - + )} diff --git a/x-pack/plugins/security_solution/public/timelines/links.ts b/x-pack/plugins/security_solution/public/timelines/links.ts index 9315417d97646..97667c0ce8aa3 100644 --- a/x-pack/plugins/security_solution/public/timelines/links.ts +++ b/x-pack/plugins/security_solution/public/timelines/links.ts @@ -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', + }, ], }; From a6fbb4e747e7926bc078b48d6269b9158850a468 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 19:02:36 +0000 Subject: [PATCH 18/20] Fix types --- .../common/components/link_to/redirect_to_timelines.tsx | 2 +- .../open_timeline_modal/open_timeline_modal_body.tsx | 8 ++++++-- .../components/open_timeline/timelines_table/mocks.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx index 2423d3493d9eb..98f47334ceca7 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_timelines.tsx @@ -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) => diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 6e012c65478c8..7eb3c65f427ac 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -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( title, totalSearchResultsCount, }) => { + const tableRef = useRef | null>(null); + const actionsToShow = useMemo(() => { const actions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; @@ -118,6 +121,7 @@ export const OpenTimelineModalBody = memo( sortField={sortField} timelineType={timelineType} totalSearchResultsCount={totalSearchResultsCount} + tableRef={tableRef} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts index 804d1625df842..075f4aca49f3f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts @@ -33,4 +33,5 @@ export const getMockTimelinesTableProps = ( sortField: DEFAULT_SORT_FIELD, timelineType: TimelineType.default, totalSearchResultsCount: mockOpenTimelineResults.length, + tableRef: { current: null }, }); From 88213560b8eecde8cdd82a332fb0467cb8c2c54d Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 20:45:07 +0000 Subject: [PATCH 19/20] Fix tests --- .../left/components/notes_list.test.tsx | 18 +++++------ .../open_timeline/use_timeline_types.test.tsx | 31 ++++++++++++++----- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx index ec80806d4f718..0c97b9f150f3b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx @@ -41,7 +41,7 @@ jest.mock('react-redux', () => { const renderNotesList = () => render( - + ); @@ -69,7 +69,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); @@ -115,7 +115,7 @@ describe('NotesList', () => { render( - + ); @@ -147,7 +147,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); const { getByText } = within(getByTestId(`${NOTE_AVATAR_TEST_ID}-0`)); @@ -169,7 +169,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); @@ -203,7 +203,7 @@ describe('NotesList', () => { const { getByTestId } = render( - + ); @@ -228,7 +228,7 @@ describe('NotesList', () => { render( - + ); @@ -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( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx index b7bb49c67bb60..e04ee0f434588 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx @@ -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}); From ec24830afe9eb5c499a1f0b589dc59b47f7a3f91 Mon Sep 17 00:00:00 2001 From: Kevin Qualters Date: Mon, 1 Jul 2024 21:50:54 +0000 Subject: [PATCH 20/20] Fix test and remove unneeded selector --- .../document_details/left/components/notes_list.test.tsx | 2 +- .../security_solution/public/notes/components/utility_bar.tsx | 3 +-- .../public/notes/pages/note_management_page.tsx | 3 +-- .../security_solution/public/notes/store/notes.slice.test.ts | 3 +-- .../security_solution/public/notes/store/notes.slice.ts | 2 -- 5 files changed, 4 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx index 0c97b9f150f3b..e35d71ec28d55 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/notes_list.test.tsx @@ -131,7 +131,7 @@ describe('NotesList', () => { ...mockGlobalState.notes, entities: { '1': { - eventId: 'event-id', + eventId: 'document-id-1', noteId: '1', note: 'note-1', timelineId: '', diff --git a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx index 65e64ac37dff2..c6b54e473ae5c 100644 --- a/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/notes/components/utility_bar.tsx @@ -17,7 +17,6 @@ import { import { selectNotesPagination, selectNotesTableSort, - selectNotesTableTotalItems, fetchNotes, selectNotesTableSelectedIds, selectNotesTableSearch, @@ -29,7 +28,7 @@ export const NotesUtilityBar = React.memo(() => { const dispatch = useDispatch(); const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); - const totalItems = useSelector(selectNotesTableTotalItems); + const totalItems = pagination.total ?? 0; const selectedItems = useSelector(selectNotesTableSelectedIds); const resultsCount = useMemo(() => { const { perPage, page } = pagination; diff --git a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx index 2370c1d6d4d21..dca13ce2eed7b 100644 --- a/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx +++ b/x-pack/plugins/security_solution/public/notes/pages/note_management_page.tsx @@ -20,7 +20,6 @@ import { selectAllNotes, selectNotesPagination, selectNotesTableSort, - selectNotesTableTotalItems, fetchNotes, selectNotesTableSearch, selectFetchNotesStatus, @@ -73,7 +72,7 @@ export const NoteManagementPage = () => { const notes = useSelector(selectAllNotes); const pagination = useSelector(selectNotesPagination); const sort = useSelector(selectNotesTableSort); - const totalItems = useSelector(selectNotesTableTotalItems); + const totalItems = pagination.total ?? 0; const notesSearch = useSelector(selectNotesTableSearch); const pendingDeleteIds = useSelector(selectNotesTablePendingDeleteIds); const isDeleteModalVisible = pendingDeleteIds.length > 0; diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts index 0fe7dad85c550..8290edb049e1e 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.test.ts @@ -32,7 +32,6 @@ import { selectNotesTableSearch, selectNotesTableSelectedIds, selectNotesTableSort, - selectNotesTableTotalItems, userClosedDeleteModal, userFilteredNotes, userSearchedNotes, @@ -544,7 +543,7 @@ describe('notesSlice', () => { pagination: { ...initialNotesState.pagination, total: 100 }, }, }; - expect(selectNotesTableTotalItems(state)).toBe(100); + expect(selectNotesPagination(state).total).toBe(100); }); it('should select notes table selected ids', () => { diff --git a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts index 59eab15303185..6b466d62f53b6 100644 --- a/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts +++ b/x-pack/plugins/security_solution/public/notes/store/notes.slice.ts @@ -251,8 +251,6 @@ export const selectNotesPagination = (state: State) => state.notes.pagination; export const selectNotesTableSort = (state: State) => state.notes.sort; -export const selectNotesTableTotalItems = (state: State) => state.notes.pagination.total; - export const selectNotesTableSelectedIds = (state: State) => state.notes.selectedIds; export const selectNotesTableSearch = (state: State) => state.notes.search;