From 432a2d9e7f640ab057997fdefbf4f3fa4d5a1557 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 24 Feb 2020 13:29:30 +0000 Subject: [PATCH 01/37] update layout --- .../utility_bar/utility_bar_text.tsx | 2 +- .../open_timeline/open_timeline.tsx | 123 +++++++++++------- .../open_timeline/search_row/index.tsx | 82 +++++------- .../timelines_table/icon_header_columns.tsx | 47 ++++++- .../open_timeline/timelines_table/index.tsx | 25 ++-- .../open_timeline/title_row/index.tsx | 5 +- .../public/pages/timelines/timelines_page.tsx | 60 ++++++--- 7 files changed, 219 insertions(+), 125 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx index 6195e008dbe27..4642b04ed4366 100644 --- a/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx +++ b/x-pack/legacy/plugins/siem/public/components/detection_engine/utility_bar/utility_bar_text.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { BarText } from './styles'; export interface UtilityBarTextProps { - children: string; + children: string | JSX.Element; } export const UtilityBarText = React.memo(({ children }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 8aab02b495392..fa9bfe3139072 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -5,13 +5,20 @@ */ import { EuiPanel } from '@elastic/eui'; -import React from 'react'; - +import React, { useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; +import { + UtilityBar, + UtilityBarSection, + UtilityBarGroup, + UtilityBarText, +} from '../detection_engine/utility_bar'; +import * as i18n from './translations'; export const OpenTimeline = React.memo( ({ @@ -37,50 +44,78 @@ export const OpenTimeline = React.memo( sortField, title, totalSearchResultsCount, - }) => ( - - + }) => { + const text = useMemo( + () => ( + + {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} + + ), + }} + /> + ), + [totalSearchResultsCount] + ); + return ( + + + + - + + + + {text} + + + - - - ) + + + ); + } ); OpenTimeline.displayName = 'OpenTimeline'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx index 5765d31078bcf..30768d1d4867b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx @@ -14,11 +14,17 @@ import { EuiText, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import * as i18n from '../translations'; import { OpenTimelineProps } from '../types'; +import { + UtilityBarSection, + UtilityBarGroup, + UtilityBarText, + UtilityBar, +} from '../../detection_engine/utility_bar'; const SearchRowContainer = styled.div` &:not(:last-child) { @@ -43,52 +49,36 @@ type Props = Pick< * Renders the row containing the search input and Only Favorites filter */ export const SearchRow = React.memo( - ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => ( - - - - - - - - - - {i18n.ONLY_FAVORITES} - - - - + ({ onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount }) => { + return ( + + + + + - -

- - {query.trim().length ? `${i18n.WITH} "${query.trim()}"` : ''} - - ), - }} - /> -

-
-
- ) + + + + {i18n.ONLY_FAVORITES} + + + + +
+ ); + } ); SearchRow.displayName = 'SearchRow'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx index 5b0f3ded7d71b..ea53e141d28ed 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,18 +6,49 @@ /* eslint-disable react/display-name */ -import { EuiIcon, EuiToolTip } from '@elastic/eui'; -import React from 'react'; +import { EuiIcon, EuiToolTip, EuiButtonIcon, EuiPopover } from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; import { getNotesCount, getPinnedEventCount } from '../helpers'; import * as i18n from '../translations'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; +const EditTimelineActions = React.memo<{ actionsColumns: [] }>(({ actionsColumns }) => { + const [isPopoverOpen, setPopover] = useState(false); + const tooglePopover = useCallback( + (newState: boolean) => { + setPopover(newState); + }, + [setPopover] + ); + return ( + + } + isOpen={isPopoverOpen} + closePopover={tooglePopover.bind(null, false)} + > + {actionsColumns?.length && + actionsColumns?.map((action, idx) => { + return {`item ${idx} `}; + })} + + ); +}); + +EditTimelineActions.displayName = 'EditTimelineActions'; /** * Returns the columns that have icon headers */ -export const getIconHeaderColumns = () => [ +export const getIconHeaderColumns = actionsColumns => [ { align: 'center', field: 'pinnedEventIds', @@ -64,4 +95,14 @@ export const getIconHeaderColumns = () => [ sortable: false, width: ACTION_COLUMN_WIDTH, }, + { + align: 'center', + field: 'visControls', + name: null, + render: () => { + return ; + }, + sortable: false, + width: ACTION_COLUMN_WIDTH, + }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index f09a9f6af048b..c9cc5a3a1b501 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -46,6 +46,7 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => * view, and the full view shown in the `All Timelines` view of the * `Timelines` page */ + const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, @@ -60,20 +61,22 @@ const getTimelinesTableColumns = ({ onOpenTimeline: OnOpenTimeline; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; -}) => [ - ...getCommonColumns({ - itemIdToExpandedNotesRowMap, - onOpenTimeline, - onToggleShowNotes, - }), - ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(), - ...getActionsColumns({ +}) => { + const actionsColumns = getActionsColumns({ deleteTimelines, onOpenTimeline, actionTimelineToShow, - }), -]; + }); + return [ + ...getCommonColumns({ + itemIdToExpandedNotesRowMap, + onOpenTimeline, + onToggleShowNotes, + }), + ...getExtendedColumnsIfEnabled(showExtendedColumns), + ...getIconHeaderColumns(actionsColumns), + ]; +}; export interface TimelinesTableProps { actionTimelineToShow: ActionTimelineToShow[]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx index c7de367e04364..a11cbf616adc6 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx @@ -14,6 +14,7 @@ import { HeaderSection } from '../../header_section'; type Props = Pick & { /** The number of timelines currently selected */ selectedTimelinesCount: number; + children?: JSX.Element; }; /** @@ -21,7 +22,7 @@ type Props = Pick( - ({ onAddTimelinesToFavorites, onDeleteSelected, selectedTimelinesCount, title }) => ( + ({ children, onAddTimelinesToFavorites, onDeleteSelected, selectedTimelinesCount, title }) => ( {(onAddTimelinesToFavorites || onDeleteSelected) && ( @@ -39,6 +40,8 @@ export const TitleRow = React.memo( )} + {children && {children}} + {onDeleteSelected && ( = ({ apolloClient }) => ( - <> - - - - - - - - - - -); +const TimelinesPageComponent: React.FC = ({ apolloClient }) => { + const [showImportModal, setShowImportModal] = useState(false); + return ( + <> + setShowImportModal(false)} + importComplete={() => { + /* setImportCompleteToggle(!importCompleteToggle)*/ + }} + /> + + + { + setShowImportModal(true); + }} + > + {'Import Timeline'} + + + + + + + + + + + ); +}; export const TimelinesPage = React.memo(TimelinesPageComponent); From 11c2f6507171233d20c29257bd20cd9c7a61b791 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 24 Feb 2020 14:11:37 +0000 Subject: [PATCH 02/37] add utility bars --- .../open_timeline/open_timeline.tsx | 50 ++++++++++++++++++- .../components/open_timeline/translations.ts | 7 +++ .../public/pages/timelines/timelines_page.tsx | 14 ++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index fa9bfe3139072..44813a43d7b9e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import React, { useMemo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; @@ -17,8 +17,11 @@ import { UtilityBarSection, UtilityBarGroup, UtilityBarText, + UtilityBarAction, } from '../detection_engine/utility_bar'; import * as i18n from './translations'; +import { BATCH_ACTIONS, REFRESH } from '../../pages/detection_engine/rules/translations'; +import { useStateToaster } from '../toasters'; export const OpenTimeline = React.memo( ({ @@ -45,6 +48,7 @@ export const OpenTimeline = React.memo( title, totalSearchResultsCount, }) => { + const [, dispatchToaster] = useStateToaster(); const text = useMemo( () => ( ( ), [totalSearchResultsCount] ); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => ( + { + closePopover(); + }} + > + {'Batch Item'} + , + ] + } + /> + ), + [selectedItems, /* dispatch, */ dispatchToaster, history] + ); + return ( ( {text} + + + {i18n.SELECTED_TIMELINES(selectedItems.length)} + + {BATCH_ACTIONS} + + null /* dispatch({ type: 'refresh' })*/} + > + {REFRESH} + + diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index b4e0d9967f2a9..ca6936f428524 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -107,3 +107,10 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( defaultMessage: '0 timelines match the search criteria', } ); + +export const SELECTED_TIMELINES = (selectedTimelines: number) => + i18n.translate('xpack.siem.open.timeline.selectedTimelinesTitle', { + values: { selectedTimelines }, + defaultMessage: + 'Selected {selectedTimelines} {selectedTimelines, plural, =1 {timeline} other {timelines}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index b6b725a7a198f..40431828d03db 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -5,7 +5,7 @@ */ import ApolloClient from 'apollo-client'; -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; import { EuiButton } from '@elastic/eui'; @@ -30,11 +30,17 @@ export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const TimelinesPageComponent: React.FC = ({ apolloClient }) => { const [showImportModal, setShowImportModal] = useState(false); + const toggleImportModal = useCallback( + newState => { + setShowImportModal(newState); + }, + [setShowImportModal] + ); return ( <> setShowImportModal(false)} + closeModal={toggleImportModal.bind(null, false)} importComplete={() => { /* setImportCompleteToggle(!importCompleteToggle)*/ }} @@ -44,9 +50,7 @@ const TimelinesPageComponent: React.FC = ({ apolloClient }) => { { - setShowImportModal(true); - }} + onClick={toggleImportModal.bind(null, true)} > {'Import Timeline'} From f32c095fa85d64eaf71673abffbe7a120c7434f6 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 24 Feb 2020 16:35:34 +0000 Subject: [PATCH 03/37] add icon --- .../siem/public/components/open_timeline/open_timeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 44813a43d7b9e..2ebc458699bdf 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -76,13 +76,13 @@ export const OpenTimeline = React.memo( [ { closePopover(); }} > - {'Batch Item'} + {'Delete selected timeline'} , ] } From 2ee1a0e67911ea52eb86bdf1bfa49c641554655a Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 4 Mar 2020 16:55:47 +0000 Subject: [PATCH 04/37] adding a route for exporting timeline --- .../legacy/plugins/siem/common/constants.ts | 3 + .../open_timeline/open_timeline.tsx | 239 ++++++++++++------ .../timelines_table/actions_columns.tsx | 92 ++++--- .../timelines_table/icon_header_columns.tsx | 7 +- .../containers/detection_engine/rules/api.ts | 12 +- .../detection_engine/rules/types.ts | 4 +- .../detection_engine/rules/all/index.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../rule_actions_overflow/index.tsx | 2 +- .../components/rule_downloader/index.tsx | 46 +++- .../server/graphql/timeline/schema.gql.ts | 2 +- .../timeline/routes/export_timelines_route.ts | 164 ++++++++++++ .../routes/schemas/export_timelines_schema.ts | 20 ++ .../lib/timeline/routes/schemas/schemas.ts | 12 + .../siem/server/lib/timeline/saved_object.ts | 34 ++- .../plugins/siem/server/lib/timeline/types.ts | 30 +++ .../plugins/siem/server/routes/index.ts | 3 + 17 files changed, 518 insertions(+), 156 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 2a30293c244af..305f5246ce41a 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -72,6 +72,9 @@ export const DETECTION_ENGINE_TAGS_URL = `${DETECTION_ENGINE_URL}/tags`; export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/_find_statuses`; export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`; +export const TIMELINE_URL = '/api/timeline'; +export const TIMELINE_EXPORT_URL = '/api/timeline/_export'; + /** * Default signals index key for kibana.dev.yml */ diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 2ebc458699bdf..afd1305ebbf36 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -5,8 +5,9 @@ */ import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import uuid from 'uuid'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; @@ -19,9 +20,24 @@ import { UtilityBarText, UtilityBarAction, } from '../detection_engine/utility_bar'; + import * as i18n from './translations'; -import { BATCH_ACTIONS, REFRESH } from '../../pages/detection_engine/rules/translations'; +import { + BATCH_ACTIONS, + BATCH_ACTION_EXPORT_SELECTED, + REFRESH, + EXPORT_FILENAME, + SUCCESSFULLY_EXPORTED_RULES, +} from '../../pages/detection_engine/rules/translations'; import { useStateToaster } from '../toasters'; +import { + RuleDownloader, + ExportSelectedData, +} from '../../pages/detection_engine/rules/components/rule_downloader'; + +import { TIMELINE_EXPORT_URL } from '../../../common/constants'; +import { throwIfNotOk } from '../../hooks/api/api'; +import { KibanaServices } from '../../lib/kibana'; export const OpenTimeline = React.memo( ({ @@ -49,6 +65,8 @@ export const OpenTimeline = React.memo( totalSearchResultsCount, }) => { const [, dispatchToaster] = useStateToaster(); + const [enableDownloader, setEnableDownloader] = useState(false); + const text = useMemo( () => ( ( [totalSearchResultsCount] ); + const exportSelectedTimeline: ExportSelectedData = useCallback( + async ({ + excludeExportDetails = false, + filename = `timelines_export.ndjson`, + ids = [], + signal, + }): Promise => { + const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; + const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { + method: 'POST', + body, + query: { + exclude_export_details: excludeExportDetails, + file_name: filename, + }, + signal, + asResponse: true, + }); + + await throwIfNotOk(response.response); + return response.body!; + }, + [] + ); + const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( { - closePopover(); - }} - > - {'Delete selected timeline'} - , - ] - } + items={[ + { + closePopover(); + + setEnableDownloader(true); + }} + > + {BATCH_ACTION_EXPORT_SELECTED} + , + { + closePopover(); + if (typeof onDeleteSelected === 'function') onDeleteSelected(); + }} + > + {'Delete selected timeline'} + , + ]} /> ), - [selectedItems, /* dispatch, */ dispatchToaster, history] + [selectedItems, dispatchToaster, history] ); return ( - - - + {enableDownloader && ( + item.savedObjectId != null) + .map(item => item.savedObjectId)} + exportSelectedData={exportSelectedTimeline} + onExportComplete={exportCount => { + setEnableDownloader(false); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: SUCCESSFULLY_EXPORTED_RULES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }} + onExportFailure={() => { + setEnableDownloader(false); + }} /> - + )} + + + + - - - - {text} - + + + + {text} + - - {i18n.SELECTED_TIMELINES(selectedItems.length)} - - {BATCH_ACTIONS} - - null /* dispatch({ type: 'refresh' })*/} - > - {REFRESH} - - - - + + {i18n.SELECTED_TIMELINES(selectedItems.length)} + + {BATCH_ACTIONS} + + null}> + {REFRESH} + + + + - - + + + ); } ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 2b8bd3339cca2..33675c35bebb8 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -6,7 +6,7 @@ /* eslint-disable react/display-name */ -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiToolTip, EuiIcon } from '@elastic/eui'; import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; @@ -32,50 +32,60 @@ export const getActionsColumns = ({ onOpenTimeline: OnOpenTimeline; }) => { const openAsDuplicateColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( - - - onOpenTimeline({ - duplicate: true, - timelineId: `${timelineResult.savedObjectId}`, - }) - } - size="s" - /> - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.OPEN_AS_DUPLICATE, + icon: , + // onClick: () => { + // onOpenTimeline({ + // duplicate: true, + // timelineId: `${timelineResult.savedObjectId}`, + // }); + // }, + // render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( + // + // + // onOpenTimeline({ + // duplicate: true, + // timelineId: `${timelineResult.savedObjectId}`, + // }) + // } + // size="s" + // /> + // + // ), + // sortable: false, + // width: ACTION_COLUMN_WIDTH, }; const deleteTimelineColumn = { - align: 'center', - field: 'savedObjectId', - name: '', - render: (savedObjectId: string, { title }: OpenTimelineResult) => ( - - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, + name: i18n.DELETE, + icon: , + // render: (savedObjectId: string, { title }: OpenTimelineResult) => ( + // + // ), + // sortable: false, + // width: ACTION_COLUMN_WIDTH, }; return [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter(action => action != null); + { + id: 0, + title: '', + items: [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null), + }, + ]; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx index ea53e141d28ed..6829bd5821a67 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,7 +6,7 @@ /* eslint-disable react/display-name */ -import { EuiIcon, EuiToolTip, EuiButtonIcon, EuiPopover } from '@elastic/eui'; +import { EuiIcon, EuiToolTip, EuiButtonIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import React, { useState, useCallback } from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; @@ -36,10 +36,7 @@ const EditTimelineActions = React.memo<{ actionsColumns: [] }>(({ actionsColumns isOpen={isPopoverOpen} closePopover={tooglePopover.bind(null, false)} > - {actionsColumns?.length && - actionsColumns?.map((action, idx) => { - return {`item ${idx} `}; - })} + ); }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index dfd812251e3d6..43ec5722bfe61 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,7 +16,7 @@ import { FetchRuleProps, BasicFetchProps, ImportRulesProps, - ExportRulesProps, + ExportDocumentsProps, RuleError, RuleStatusResponse, ImportRulesResponse, @@ -265,7 +265,7 @@ export const importRules = async ({ * * @param excludeExportDetails whether or not to exclude additional details at bottom of exported file (defaults to false) * @param filename of exported rules. Be sure to include `.ndjson` extension! (defaults to localized `rules_export.ndjson`) - * @param ruleIds array of rule_id's (not id!) to export (empty array exports _all_ rules) + * @param ids array of rule_id's (not id!) to export (empty array exports _all_ rules) * @param signal AbortSignal for cancelling request * * @throws An error if response is not OK @@ -273,13 +273,11 @@ export const importRules = async ({ export const exportRules = async ({ excludeExportDetails = false, filename = `${i18n.EXPORT_FILENAME}.ndjson`, - ruleIds = [], + ids = [], signal, -}: ExportRulesProps): Promise => { +}: ExportDocumentsProps): Promise => { const body = - ruleIds.length > 0 - ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) - : undefined; + ids.length > 0 ? JSON.stringify({ objects: ids.map(rule => ({ rule_id: rule })) }) : undefined; const response = await KibanaServices.get().http.fetch( `${DETECTION_ENGINE_RULES_URL}/_export`, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index ff49bb8a8c3a2..7da947d14eaa5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -175,8 +175,8 @@ export interface ImportRulesResponse { errors: ImportRulesResponseError[]; } -export interface ExportRulesProps { - ruleIds?: string[]; +export interface ExportDocumentsProps { + ids?: string[]; filename?: string; excludeExportDetails?: boolean; signal: AbortSignal; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 79fec526faf48..51ceb16e7320e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -244,7 +244,7 @@ export const AllRules = React.memo( <> { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index 9355d0ae2cccb..a6de98de9020a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -58,7 +58,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` `; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index 7c8926c2064c7..f310f92ef0c66 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -131,7 +131,7 @@ const RuleActionsOverflowComponent = ({ { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx index 5d3086051a6e2..efee5ccb4a3ba 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx @@ -6,8 +6,11 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { isFunction } from 'lodash/fp'; -import { exportRules } from '../../../../../containers/detection_engine/rules'; +import { isFunction, isNil } from 'lodash/fp'; +import { + exportRules, + ExportDocumentsProps, +} from '../../../../../containers/detection_engine/rules'; import { displayErrorToast, useStateToaster } from '../../../../../components/toasters'; import * as i18n from './translations'; @@ -15,10 +18,19 @@ const InvisibleAnchor = styled.a` display: none; `; +export type ExportSelectedData = ({ + excludeExportDetails, + filename, + ids, + signal, +}: ExportDocumentsProps) => Promise; + export interface RuleDownloaderProps { filename: string; - ruleIds?: string[]; + ids?: string[]; + exportSelectedData?: ExportSelectedData; onExportComplete: (exportCount: number) => void; + onExportFailure?: () => void; } /** @@ -29,9 +41,11 @@ export interface RuleDownloaderProps { * */ export const RuleDownloaderComponent = ({ + exportSelectedData, filename, - ruleIds, + ids, onExportComplete, + onExportFailure, }: RuleDownloaderProps) => { const anchorRef = useRef(null); const [, dispatchToaster] = useStateToaster(); @@ -41,13 +55,20 @@ export const RuleDownloaderComponent = ({ const abortCtrl = new AbortController(); async function exportData() { - if (anchorRef && anchorRef.current && ruleIds != null && ruleIds.length > 0) { + if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { + let exportResponse; try { - const exportResponse = await exportRules({ - ruleIds, - signal: abortCtrl.signal, - }); - + if (isNil(exportSelectedData)) { + exportResponse = await exportRules({ + ids, + signal: abortCtrl.signal, + }); + } else { + exportResponse = await exportSelectedData({ + ids, + signal: abortCtrl.signal, + }); + } if (isSubscribed) { // this is for supporting IE if (isFunction(window.navigator.msSaveOrOpenBlob)) { @@ -61,11 +82,12 @@ export const RuleDownloaderComponent = ({ window.URL.revokeObjectURL(objectURL); } - onExportComplete(ruleIds.length); + if (typeof onExportComplete === 'function') onExportComplete(ids.length); } } catch (error) { if (isSubscribed) { displayErrorToast(i18n.EXPORT_FAILURE, [error.message], dispatchToaster); + if (typeof onExportFailure === 'function') onExportFailure(); } } } @@ -77,7 +99,7 @@ export const RuleDownloaderComponent = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [ruleIds]); + }, [ids]); return ; }; diff --git a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts index 8b24cea0d6af9..9dd04247b7f47 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/timeline/schema.gql.ts @@ -150,7 +150,7 @@ export const timelineSchema = gql` updated created } - + input SortTimeline { sortField: SortFieldTimeline! sortOrder: Direction! diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts new file mode 100644 index 0000000000000..faf98e0192d8a --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; + +import { set as _set } from 'lodash/fp'; +import { RequestHandlerContext } from '../../../../../../../../src/core/server'; +import { GetScopedClients } from '../../../services'; +import { LegacyServices, LegacyRequest } from '../../../types'; +import { ExportTimelineRequest, ExportTimelineResults } from '../types'; +import { timelineSavedObjectType } from '../../../saved_objects'; +import { PinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { transformRulesToNdjson } from '../../detection_engine/routes/rules/utils'; +import { Note } from '../../note/saved_object'; +import { timelineWithReduxProperties } from '../saved_object'; +import { transformError } from '../../detection_engine/routes/utils'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +import { SavedObjectsClient } from '../../../../../../../../src/legacy/server/kbn_server'; +import { + exportTimelinesSchema, + exportTimelinesQuerySchema, +} from './schemas/export_timelines_schema'; +import { FrameworkRequest } from '../../framework'; + +const getExportTimelineByObjectIds = async ( + client: Pick< + SavedObjectsClient, + | 'get' + | 'delete' + | 'errors' + | 'create' + | 'bulkCreate' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' + >, + request: ExportTimelineRequest & LegacyRequest +) => { + const { timeline } = await getTimelinesFromObjects(client, request); + + const timelinesNdjson = transformRulesToNdjson(timeline); + return { timelinesNdjson }; +}; + +const getTimelinesFromObjects = async ( + client: Pick< + SavedObjectsClient, + | 'get' + | 'delete' + | 'errors' + | 'create' + | 'bulkCreate' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' + >, + request: ExportTimelineRequest & LegacyRequest +): Promise => { + const note = new Note(); + const pinnedEvent = new PinnedEvent(); + const savedObjects = await client.bulkGet( + request?.payload?.objects?.map(id => ({ id, type: timelineSavedObjectType })) + ); + + const requestWithClient: FrameworkRequest & RequestHandlerContext = { + ...request, + context: { + core: { + savedObjects: { + client, + }, + }, + }, + }; + const timelinesWithNotesAndPinnedEvents = await Promise.all( + savedObjects.saved_objects.map(async savedObject => { + const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject); + return Promise.all([ + note.getNotesByTimelineId(requestWithClient, timelineSaveObject.savedObjectId), + pinnedEvent.getAllPinnedEventsByTimelineId( + requestWithClient, + timelineSaveObject.savedObjectId + ), + Promise.resolve(timelineSaveObject), + ]); + }) + ); + + return { + timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) => + timelineWithReduxProperties(notes, pinnedEvents, timeline) + ), + }; +}; + +const createExportTimelinesRoute = ( + config: LegacyServices['config'], + getClients: GetScopedClients +): Hapi.ServerRoute => { + return { + method: 'POST', + path: TIMELINE_EXPORT_URL, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + payload: exportTimelinesSchema, + query: exportTimelinesQuerySchema, + }, + }, + async handler(request: ExportTimelineRequest & LegacyRequest, headers) { + const { savedObjectsClient } = await getClients(request); + + if (!savedObjectsClient) { + return headers.response().code(404); + } + + try { + const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); + if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) { + return headers + .response({ + message: `Can't export more than ${exportSizeLimit} rules`, + status_code: 400, + }) + .code(400); + } + + const exported = await getExportTimelineByObjectIds(savedObjectsClient, request); + + const response = headers.response(exported.timelinesNdjson); + + return response + .header('Content-Disposition', `attachment; filename="${request.query.file_name}"`) + .header('Content-Type', 'application/ndjson'); + } catch (err) { + const error = transformError(err); + return headers + .response({ + message: error.message, + status_code: error.statusCode, + }) + .code(error.statusCode); + } + }, + }; +}; + +export const exportTimelinesRoute = ( + route: LegacyServices['route'], + config: LegacyServices['config'], + getClients: GetScopedClients +): void => { + route(createExportTimelinesRoute(config, getClients)); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts new file mode 100644 index 0000000000000..ab7cc0175810e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +import { objects, exclude_export_details, file_name } from './schemas'; +/* eslint-disable @typescript-eslint/camelcase */ + +export const exportTimelinesSchema = Joi.object({ + objects, +}).min(1); + +export const exportTimelinesQuerySchema = Joi.object({ + file_name: file_name.default('export.ndjson'), + exclude_export_details: exclude_export_details.default(false), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts new file mode 100644 index 0000000000000..6f471342a4f02 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Joi from 'joi'; + +/* eslint-disable @typescript-eslint/camelcase */ +export const objects = Joi.array().items(Joi.string().required()); + +export const exclude_export_details = Joi.boolean(); +export const file_name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index 4b78a7bd3d06d..e3a141045e85f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -260,6 +260,32 @@ export class Timeline { ), }; } + + // public async exportTimeline(request: FrameworkRequest, timelineIds: string[]) { + // const savedObjectsClient = request.context.core.savedObjects.client; + // const savedObjects = await savedObjectsClient.bulkGet( + // timelineIds.map(id => ({ id, type: timelineSavedObjectType })) + // ); + // const timelinesWithNotesAndPinnedEvents = await Promise.all( + // savedObjects.saved_objects.map(async savedObject => { + // const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject); + // return Promise.all([ + // this.note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId), + // this.pinnedEvent.getAllPinnedEventsByTimelineId( + // request, + // timelineSaveObject.savedObjectId + // ), + // Promise.resolve(timelineSaveObject), + // ]); + // }) + // ); + + // return { + // timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) => + // timelineWithReduxProperties(notes, pinnedEvents, timeline) + // ), + // }; + // } } export const convertStringToBase64 = (text: string): string => Buffer.from(text).toString('base64'); @@ -271,15 +297,17 @@ export const convertStringToBase64 = (text: string): string => Buffer.from(text) // then this interface does not allow types without index signature // this is limiting us with our type for now so the easy way was to use any -const timelineWithReduxProperties = ( +export const timelineWithReduxProperties = ( notes: NoteSavedObject[], pinnedEvents: PinnedEventSavedObject[], timeline: TimelineSavedObject, - userName: string + userName?: string ): TimelineSavedObject => ({ ...timeline, favorite: - timeline.favorite != null ? timeline.favorite.filter(fav => fav.userName === userName) : [], + timeline.favorite != null && userName != null + ? timeline.favorite.filter(fav => fav.userName === userName) + : [], eventIdToNoteIds: notes.filter(note => note.eventId != null), noteIds: notes .filter(note => note.eventId == null && note.noteId != null) diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index d757ea8049bc1..db03d41d9a3cd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -11,6 +11,8 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; import { NoteSavedObjectToReturnRuntimeType } from '../note/types'; import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; +import { LegacyRequest } from '../../types'; +import { ColumnHeaderResult, Maybe, DataProviderResult, NoteResult } from '../../graphql/types'; /* * ColumnHeader Types @@ -199,3 +201,31 @@ export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} + +export interface ExportTimelineRequest extends Omit { + payload: { objects: Array<{ timelime_id: string }> | null | undefined }; + query: { + file_name: string; + exclude_export_details: boolean; + }; +} + +export interface ExportTimelineResults { + timeline: Array>; +} + +export interface ExportTimelineResult { + columns?: Maybe; + + dataProviders?: Maybe; + + description?: Maybe; + + eventNotes?: Maybe; + + globalNotes?: Maybe; + + pinnedEventIds?: Maybe; + + title?: Maybe; +} diff --git a/x-pack/legacy/plugins/siem/server/routes/index.ts b/x-pack/legacy/plugins/siem/server/routes/index.ts index 82fc4d8c11722..81d55ca72e536 100644 --- a/x-pack/legacy/plugins/siem/server/routes/index.ts +++ b/x-pack/legacy/plugins/siem/server/routes/index.ts @@ -29,6 +29,7 @@ import { importRulesRoute } from '../lib/detection_engine/routes/rules/import_ru import { exportRulesRoute } from '../lib/detection_engine/routes/rules/export_rules_route'; import { findRulesStatusesRoute } from '../lib/detection_engine/routes/rules/find_rules_status_route'; import { getPrepackagedRulesStatusRoute } from '../lib/detection_engine/routes/rules/get_prepackaged_rules_status_route'; +import { exportTimelinesRoute } from '../lib/timeline/routes/export_timelines_route'; export type LegacyInitRoutes = (getClients: GetScopedClients) => void; @@ -56,6 +57,8 @@ export const initRoutes = ( importRulesRoute(route, config, getClients); exportRulesRoute(route, config, getClients); + exportTimelinesRoute(route, config, getClients); + findRulesStatusesRoute(route, getClients); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals From 10fb49ec8c535d4be2bdf306d4dfc9bbc553efa5 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 6 Mar 2020 00:09:32 +0000 Subject: [PATCH 05/37] organizing data --- .../open_timeline/open_timeline.tsx | 13 +- .../components/rule_downloader/index.tsx | 18 +- .../siem/server/lib/note/saved_object.ts | 2 +- .../server/lib/pinned_event/saved_object.ts | 2 +- .../timeline/routes/export_timelines_route.ts | 226 +++++++++--------- .../lib/timeline/routes/schemas/schemas.ts | 11 +- .../plugins/siem/server/lib/timeline/types.ts | 4 +- 7 files changed, 146 insertions(+), 130 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index afd1305ebbf36..ce311b2569393 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -8,6 +8,7 @@ import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui' import React, { useMemo, useCallback, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import uuid from 'uuid'; +import { keys } from 'lodash/fp'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; @@ -144,14 +145,20 @@ export const OpenTimeline = React.memo( [selectedItems, dispatchToaster, history] ); + const getSelectedItemsIds = useMemo(() => { + return selectedItems.map(item => ({ + timelineId: item.savedObjectId, + pinnedEventIds: keys(item.pinnedEventIds), + noteIds: item.noteIds, + })); + }, [selectedItems]); + return ( <> {enableDownloader && ( item.savedObjectId != null) - .map(item => item.savedObjectId)} + ids={getSelectedItemsIds} exportSelectedData={exportSelectedTimeline} onExportComplete={exportCount => { setEnableDownloader(false); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx index efee5ccb4a3ba..6c80aa7fa314c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx @@ -25,9 +25,16 @@ export type ExportSelectedData = ({ signal, }: ExportDocumentsProps) => Promise; +interface ExportTimelineIds { + timelineId: string; + noteIds: string[]; + pinnedEventIds: string[]; +} + export interface RuleDownloaderProps { filename: string; - ids?: string[]; + ids?: ExportTimelineIds[]; + ruleIds?: string[]; exportSelectedData?: ExportSelectedData; onExportComplete: (exportCount: number) => void; onExportFailure?: () => void; @@ -44,6 +51,7 @@ export const RuleDownloaderComponent = ({ exportSelectedData, filename, ids, + ruleIds, onExportComplete, onExportFailure, }: RuleDownloaderProps) => { @@ -55,12 +63,16 @@ export const RuleDownloaderComponent = ({ const abortCtrl = new AbortController(); async function exportData() { - if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { + if ( + anchorRef && + anchorRef.current && + ((ruleIds != null && ruleIds.length > 0) || (ids != null && ids.length > 0)) + ) { let exportResponse; try { if (isNil(exportSelectedData)) { exportResponse = await exportRules({ - ids, + ruleIds, signal: abortCtrl.signal, }); } else { diff --git a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts index d825aae1b480b..b6a43fc523adb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/note/saved_object.ts @@ -194,7 +194,7 @@ export class Note { } } -const convertSavedObjectToSavedNote = ( +export const convertSavedObjectToSavedNote = ( savedObject: unknown, timelineVersion?: string | undefined | null ): NoteSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts index afa3595a09e1c..9ea950e8a443b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/pinned_event/saved_object.ts @@ -180,7 +180,7 @@ export class PinnedEvent { } } -const convertSavedObjectToSavedPinnedEvent = ( +export const convertSavedObjectToSavedPinnedEvent = ( savedObject: unknown, timelineVersion?: string | undefined | null ): PinnedEventSavedObject => diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index faf98e0192d8a..990697dd2ae39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -4,161 +4,149 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; - import { set as _set } from 'lodash/fp'; -import { RequestHandlerContext } from '../../../../../../../../src/core/server'; -import { GetScopedClients } from '../../../services'; +import { SavedObjectsClient, IRouter } from '../../../../../../../../src/core/server'; import { LegacyServices, LegacyRequest } from '../../../types'; -import { ExportTimelineRequest, ExportTimelineResults } from '../types'; -import { timelineSavedObjectType } from '../../../saved_objects'; -import { PinnedEvent } from '../../pinned_event/saved_object'; +import { ExportTimelineResults, ExportTimelineRequestParams } from '../types'; +import { timelineSavedObjectType, noteSavedObjectType } from '../../../saved_objects'; + import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; import { transformRulesToNdjson } from '../../detection_engine/routes/rules/utils'; -import { Note } from '../../note/saved_object'; -import { timelineWithReduxProperties } from '../saved_object'; -import { transformError } from '../../detection_engine/routes/utils'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; + +import { + transformError, + buildRouteValidation, + buildSiemResponse, +} from '../../detection_engine/routes/utils'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; -import { SavedObjectsClient } from '../../../../../../../../src/legacy/server/kbn_server'; import { exportTimelinesSchema, exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; -import { FrameworkRequest } from '../../framework'; - -const getExportTimelineByObjectIds = async ( - client: Pick< - SavedObjectsClient, - | 'get' - | 'delete' - | 'errors' - | 'create' - | 'bulkCreate' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' - >, - request: ExportTimelineRequest & LegacyRequest -) => { - const { timeline } = await getTimelinesFromObjects(client, request); + +const getNotesByTimelineId = (notes, timelineId) => { + const initialNotes = { + eventNotes: [], + globalNotes: [], + }; + if (notes == null) return initialNotes; + const notesByTimelineId = notes?.filter(note => note.timelineId !== timelineId); + return notesByTimelineId.reduce((acc, curr) => { + if (curr.eventId == null) + return { + ...acc, + globalNotes: [...acc.globalNotes, curr], + }; + else + return { + ...acc, + eventNotes: [...acc.eventNotes, curr], + }; + }, initialNotes); +}; + +const getExportTimelineByObjectIds = async ({ client, request }) => { + const timeline = await getTimelinesFromObjects(client, request); const timelinesNdjson = transformRulesToNdjson(timeline); return { timelinesNdjson }; }; const getTimelinesFromObjects = async ( - client: Pick< - SavedObjectsClient, - | 'get' - | 'delete' - | 'errors' - | 'create' - | 'bulkCreate' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' - >, + client: SavedObjectsClient, request: ExportTimelineRequest & LegacyRequest ): Promise => { - const note = new Note(); - const pinnedEvent = new PinnedEvent(); - const savedObjects = await client.bulkGet( - request?.payload?.objects?.map(id => ({ id, type: timelineSavedObjectType })) - ); + const bulkGetTimelines = request.body.objects.map(item => ({ + id: item.timelineId, + type: timelineSavedObjectType, + })); + const bulkGetNotes = request.body.objects.reduce((acc, item) => { + return item.noteIds.length > 0 + ? [ + ...acc, + ...item.noteIds?.map(noteId => ({ + id: noteId, + type: noteSavedObjectType, + })), + ] + : acc; + }, []); - const requestWithClient: FrameworkRequest & RequestHandlerContext = { - ...request, - context: { - core: { - savedObjects: { - client, - }, - }, - }, - }; - const timelinesWithNotesAndPinnedEvents = await Promise.all( - savedObjects.saved_objects.map(async savedObject => { - const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject); - return Promise.all([ - note.getNotesByTimelineId(requestWithClient, timelineSaveObject.savedObjectId), - pinnedEvent.getAllPinnedEventsByTimelineId( - requestWithClient, - timelineSaveObject.savedObjectId - ), - Promise.resolve(timelineSaveObject), - ]); - }) + const savedObjects = await Promise.all([ + bulkGetTimelines.length > 0 ? client.bulkGet(bulkGetTimelines) : Promise.resolve({}), + bulkGetNotes.length > 0 ? client.bulkGet(bulkGetNotes) : Promise.resolve({}), + ]); + + const timelineObjects = savedObjects[0].saved_objects.map(savedObject => + convertSavedObjectToSavedTimeline(savedObject) ); - return { - timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) => - timelineWithReduxProperties(notes, pinnedEvents, timeline) - ), - }; + const noteObjects = savedObjects[1]?.saved_objects?.map(savedObject => + convertSavedObjectToSavedNote(savedObject) + ); + return timelineObjects.map((timeline, index) => { + return { + ...timeline, + ...getNotesByTimelineId(noteObjects, timeline.savedObjectId), + pinEventsIds: request.body.objects.find(item => item.timelineId === timeline.savedObjectId) + ?.pinnedEventIds, + }; + }); }; -const createExportTimelinesRoute = ( - config: LegacyServices['config'], - getClients: GetScopedClients -): Hapi.ServerRoute => { - return { - method: 'POST', - path: TIMELINE_EXPORT_URL, - options: { - tags: ['access:siem'], +export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { + router.post( + { + path: TIMELINE_EXPORT_URL, validate: { - options: { - abortEarly: false, - }, - payload: exportTimelinesSchema, - query: exportTimelinesQuerySchema, + query: buildRouteValidation( + exportTimelinesQuerySchema + ), + body: buildRouteValidation(exportTimelinesSchema), + }, + options: { + tags: ['access:siem'], }, }, - async handler(request: ExportTimelineRequest & LegacyRequest, headers) { - const { savedObjectsClient } = await getClients(request); + async (context, request, response) => { + const savedObjectsClient = context.core.savedObjects.client; + const siemResponse = buildSiemResponse(response); if (!savedObjectsClient) { - return headers.response().code(404); + return siemResponse.error({ statusCode: 404 }); } try { const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); - if (request.payload?.objects != null && request.payload.objects.length > exportSizeLimit) { - return headers - .response({ - message: `Can't export more than ${exportSizeLimit} rules`, - status_code: 400, - }) - .code(400); + if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { + return siemResponse.error({ + statusCode: 400, + body: `Can't export more than ${exportSizeLimit} rules`, + }); } + const exported = await getExportTimelineByObjectIds({ + client: savedObjectsClient, + request, + }); - const exported = await getExportTimelineByObjectIds(savedObjectsClient, request); + const responseBody = exported.timelinesNdjson; - const response = headers.response(exported.timelinesNdjson); - - return response - .header('Content-Disposition', `attachment; filename="${request.query.file_name}"`) - .header('Content-Type', 'application/ndjson'); + return response.ok({ + headers: { + 'Content-Disposition': `attachment; filename="${request.query.file_name}"`, + 'Content-Type': 'application/ndjson', + }, + body: responseBody, + }); } catch (err) { const error = transformError(err); - return headers - .response({ - message: error.message, - status_code: error.statusCode, - }) - .code(error.statusCode); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); } - }, - }; -}; - -export const exportTimelinesRoute = ( - route: LegacyServices['route'], - config: LegacyServices['config'], - getClients: GetScopedClients -): void => { - route(createExportTimelinesRoute(config, getClients)); + } + ); }; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index 6f471342a4f02..ec8378b66af13 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -6,7 +6,16 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ -export const objects = Joi.array().items(Joi.string().required()); +export const timelineId = Joi.string(); +export const pinnedEventIds = Joi.array().items(Joi.string()); +export const noteIds = Joi.array().items(Joi.string()); +export const objects = Joi.array().items( + Joi.object({ + timelineId, + pinnedEventIds, + noteIds, + }) +); export const exclude_export_details = Joi.boolean(); export const file_name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index db03d41d9a3cd..a87f66f684faf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -202,8 +202,8 @@ export const AllTimelineSavedObjectRuntimeType = runtimeTypes.type({ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} -export interface ExportTimelineRequest extends Omit { - payload: { objects: Array<{ timelime_id: string }> | null | undefined }; +export interface ExportTimelineRequestParams { + body: { objects: string[] | null | undefined }; query: { file_name: string; exclude_export_details: boolean; From fefeaa5c0f51f7b4ed9bd65a140c9cf0ea426b5c Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Fri, 6 Mar 2020 15:53:41 +0000 Subject: [PATCH 06/37] fix types --- .../components/open_timeline/open_timeline.tsx | 17 ++++++++++------- .../components/open_timeline/translations.ts | 7 +++++++ .../containers/detection_engine/rules/api.ts | 8 +++++--- .../containers/detection_engine/rules/types.ts | 4 +++- .../pages/detection_engine/rules/all/index.tsx | 2 +- .../rules/components/rule_downloader/index.tsx | 16 ++++++---------- 6 files changed, 32 insertions(+), 22 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index ce311b2569393..d7d8b6d313c80 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -28,7 +28,6 @@ import { BATCH_ACTION_EXPORT_SELECTED, REFRESH, EXPORT_FILENAME, - SUCCESSFULLY_EXPORTED_RULES, } from '../../pages/detection_engine/rules/translations'; import { useStateToaster } from '../toasters'; import { @@ -40,6 +39,12 @@ import { TIMELINE_EXPORT_URL } from '../../../common/constants'; import { throwIfNotOk } from '../../hooks/api/api'; import { KibanaServices } from '../../lib/kibana'; +export interface ExportTimelineIds { + timelineId: string | null | undefined; + pinnedEventIds: string[] | null | undefined; + noteIds: string[] | null | undefined; +} + export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -145,10 +150,11 @@ export const OpenTimeline = React.memo( [selectedItems, dispatchToaster, history] ); - const getSelectedItemsIds = useMemo(() => { + const getSelectedItemsIds: ExportTimelineIds[] = useMemo(() => { return selectedItems.map(item => ({ timelineId: item.savedObjectId, - pinnedEventIds: keys(item.pinnedEventIds), + pinnedEventIds: + item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, noteIds: item.noteIds, })); }, [selectedItems]); @@ -166,15 +172,12 @@ export const OpenTimeline = React.memo( type: 'addToaster', toast: { id: uuid.v4(), - title: SUCCESSFULLY_EXPORTED_RULES(exportCount), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), color: 'success', iconType: 'check', }, }); }} - onExportFailure={() => { - setEnableDownloader(false); - }} /> )} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index ca6936f428524..53663724c76b5 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -114,3 +114,10 @@ export const SELECTED_TIMELINES = (selectedTimelines: number) => defaultMessage: 'Selected {selectedTimelines} {selectedTimelines, plural, =1 {timeline} other {timelines}}', }); + +export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => + i18n.translate('xpack.siem.open.timeline.successfullyExportedTimelinesTitle', { + values: { totalTimelines }, + defaultMessage: + 'Successfully exported {totalTimelines, plural, =0 {all timelines} =1 {{totalTimelines} timeline} other {{totalTimelines} timelines}}', + }); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 43ec5722bfe61..e8cfabeaed8b2 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -265,7 +265,7 @@ export const importRules = async ({ * * @param excludeExportDetails whether or not to exclude additional details at bottom of exported file (defaults to false) * @param filename of exported rules. Be sure to include `.ndjson` extension! (defaults to localized `rules_export.ndjson`) - * @param ids array of rule_id's (not id!) to export (empty array exports _all_ rules) + * @param ruleIds array of rule_id's (not id!) to export (empty array exports _all_ rules) * @param signal AbortSignal for cancelling request * * @throws An error if response is not OK @@ -273,11 +273,13 @@ export const importRules = async ({ export const exportRules = async ({ excludeExportDetails = false, filename = `${i18n.EXPORT_FILENAME}.ndjson`, - ids = [], + ruleIds = [], signal, }: ExportDocumentsProps): Promise => { const body = - ids.length > 0 ? JSON.stringify({ objects: ids.map(rule => ({ rule_id: rule })) }) : undefined; + ruleIds.length > 0 + ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) + : undefined; const response = await KibanaServices.get().http.fetch( `${DETECTION_ENGINE_RULES_URL}/_export`, diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 7da947d14eaa5..a987f268ff21c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -5,6 +5,7 @@ */ import * as t from 'io-ts'; +import { ExportTimelineIds } from '../../../components/open_timeline/open_timeline'; export const NewRuleSchema = t.intersection([ t.type({ @@ -176,7 +177,8 @@ export interface ImportRulesResponse { } export interface ExportDocumentsProps { - ids?: string[]; + ids?: ExportTimelineIds[] | undefined; + ruleIds?: string[]; filename?: string; excludeExportDetails?: boolean; signal: AbortSignal; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 460bd99b78506..9676b83a26f55 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -246,7 +246,7 @@ export const AllRules = React.memo( <> { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx index 6c80aa7fa314c..f1c0234244737 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx @@ -13,6 +13,7 @@ import { } from '../../../../../containers/detection_engine/rules'; import { displayErrorToast, useStateToaster } from '../../../../../components/toasters'; import * as i18n from './translations'; +import { ExportTimelineIds } from '../../../../../components/open_timeline/open_timeline'; const InvisibleAnchor = styled.a` display: none; @@ -25,19 +26,12 @@ export type ExportSelectedData = ({ signal, }: ExportDocumentsProps) => Promise; -interface ExportTimelineIds { - timelineId: string; - noteIds: string[]; - pinnedEventIds: string[]; -} - export interface RuleDownloaderProps { filename: string; ids?: ExportTimelineIds[]; ruleIds?: string[]; exportSelectedData?: ExportSelectedData; onExportComplete: (exportCount: number) => void; - onExportFailure?: () => void; } /** @@ -47,13 +41,13 @@ export interface RuleDownloaderProps { * @param payload Rule[] * */ + export const RuleDownloaderComponent = ({ exportSelectedData, filename, ids, ruleIds, onExportComplete, - onExportFailure, }: RuleDownloaderProps) => { const anchorRef = useRef(null); const [, dispatchToaster] = useStateToaster(); @@ -94,12 +88,14 @@ export const RuleDownloaderComponent = ({ window.URL.revokeObjectURL(objectURL); } - if (typeof onExportComplete === 'function') onExportComplete(ids.length); + if (typeof onExportComplete === 'function') { + if (ruleIds != null) onExportComplete(ruleIds.length); + else if (ids != null) onExportComplete(ids.length); + } } } catch (error) { if (isSubscribed) { displayErrorToast(i18n.EXPORT_FAILURE, [error.message], dispatchToaster); - if (typeof onExportFailure === 'function') onExportFailure(); } } } From 1bf860f3bdabc34b674061115238ff75e1a26355 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 9 Mar 2020 17:51:06 +0000 Subject: [PATCH 07/37] fix incorrect props for timeline table --- .../open_timeline/open_timeline.tsx | 12 +- .../open_timeline_modal_body.tsx | 1 - .../open_timeline/search_row/index.tsx | 10 +- .../timelines_table/actions_columns.tsx | 109 ++++++------ .../timelines_table/icon_header_columns.tsx | 24 ++- .../open_timeline/title_row/index.test.tsx | 116 +----------- .../open_timeline/title_row/index.tsx | 54 ++---- .../components/open_timeline/translations.ts | 4 + .../public/components/open_timeline/types.ts | 1 + .../rule_actions_overflow/index.tsx | 2 +- .../public/pages/timelines/timelines_page.tsx | 7 +- .../public/pages/timelines/translations.ts | 7 + .../timeline/routes/export_timelines_route.ts | 166 ++++++++++++------ .../lib/timeline/routes/schemas/schemas.ts | 4 +- .../plugins/siem/server/lib/timeline/types.ts | 24 +-- 15 files changed, 242 insertions(+), 299 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index d7d8b6d313c80..1754b132d53da 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -122,7 +122,7 @@ export const OpenTimeline = React.memo( { @@ -134,7 +134,7 @@ export const OpenTimeline = React.memo( {BATCH_ACTION_EXPORT_SELECTED} , { @@ -142,7 +142,7 @@ export const OpenTimeline = React.memo( if (typeof onDeleteSelected === 'function') onDeleteSelected(); }} > - {'Delete selected timeline'} + {i18n.DELETE_SELECTED} , ]} /> @@ -155,7 +155,10 @@ export const OpenTimeline = React.memo( timelineId: item.savedObjectId, pinnedEventIds: item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, - noteIds: item.noteIds, + noteIds: item?.notes?.reduce( + (acc, note) => (note.noteId != null ? [...acc, note.noteId] : acc), + [] as string[] + ), })); }, [selectedItems]); @@ -184,7 +187,6 @@ export const OpenTimeline = React.memo( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index dcd0b37770583..60ebf2118d556 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -58,7 +58,6 @@ export const OpenTimelineModalBody = memo( { +}): EuiContextMenuPanelDescriptor[] | undefined => { const openAsDuplicateColumn = { name: i18n.OPEN_AS_DUPLICATE, icon: , - // onClick: () => { - // onOpenTimeline({ - // duplicate: true, - // timelineId: `${timelineResult.savedObjectId}`, - // }); - // }, - // render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( - // - // - // onOpenTimeline({ - // duplicate: true, - // timelineId: `${timelineResult.savedObjectId}`, - // }) - // } - // size="s" - // /> - // - // ), - // sortable: false, - // width: ACTION_COLUMN_WIDTH, }; + // onClick: () => { + // onOpenTimeline({ + // duplicate: true, + // timelineId: `${timelineResult.savedObjectId}`, + // }); + // }, + + // render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( + // + // + // onOpenTimeline({ + // duplicate: true, + // timelineId: `${timelineResult.savedObjectId}`, + // }) + // } + // size="s" + // /> + // + // ), + // sortable: false, + // width: ACTION_COLUMN_WIDTH, const deleteTimelineColumn = { name: i18n.DELETE, icon: , - // render: (savedObjectId: string, { title }: OpenTimelineResult) => ( + + // icon: ( // + // ), + // render: (savedObjectId: string, { title }: OpenTimelineResult) => ( + // // ), @@ -76,16 +82,19 @@ export const getActionsColumns = ({ // width: ACTION_COLUMN_WIDTH, }; - return [ - { - id: 0, - title: '', - items: [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter(action => action != null), - }, - ]; + const pannelItems = [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null) as EuiContextMenuPanelItemDescriptor[]; + + return pannelItems.length > 0 + ? [ + { + id: 0, + items: pannelItems, + }, + ] + : undefined; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx index 6829bd5821a67..3c48720853a93 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,7 +6,14 @@ /* eslint-disable react/display-name */ -import { EuiIcon, EuiToolTip, EuiButtonIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { + EuiIcon, + EuiToolTip, + EuiButtonIcon, + EuiPopover, + EuiContextMenu, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; import React, { useState, useCallback } from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; @@ -14,7 +21,9 @@ import { getNotesCount, getPinnedEventCount } from '../helpers'; import * as i18n from '../translations'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; -const EditTimelineActions = React.memo<{ actionsColumns: [] }>(({ actionsColumns }) => { +const EditTimelineActions = React.memo<{ + actionsColumns: EuiContextMenuPanelDescriptor[] | undefined; +}>(({ actionsColumns }) => { const [isPopoverOpen, setPopover] = useState(false); const tooglePopover = useCallback( (newState: boolean) => { @@ -45,7 +54,9 @@ EditTimelineActions.displayName = 'EditTimelineActions'; /** * Returns the columns that have icon headers */ -export const getIconHeaderColumns = actionsColumns => [ +export const getIconHeaderColumns = ( + actionsColumns: EuiContextMenuPanelDescriptor[] | undefined +) => [ { align: 'center', field: 'pinnedEventIds', @@ -95,9 +106,14 @@ export const getIconHeaderColumns = actionsColumns => [ { align: 'center', field: 'visControls', - name: null, + name: ( + + + + ), render: () => { return ; + // return xxx; }, sortable: false, width: ACTION_COLUMN_WIDTH, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx index 88dfab470ac96..fe49b05ae6275 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.test.tsx @@ -19,12 +19,7 @@ describe('TitleRow', () => { test('it renders the title', () => { const wrapper = mountWithIntl( - + ); @@ -42,7 +37,6 @@ describe('TitleRow', () => { @@ -60,7 +54,7 @@ describe('TitleRow', () => { test('it does NOT render the Favorite Selected button when onAddTimelinesToFavorites is NOT provided', () => { const wrapper = mountWithIntl( - + ); @@ -77,7 +71,6 @@ describe('TitleRow', () => { @@ -97,7 +90,6 @@ describe('TitleRow', () => { @@ -119,7 +111,6 @@ describe('TitleRow', () => { @@ -134,107 +125,4 @@ describe('TitleRow', () => { expect(onAddTimelinesToFavorites).toHaveBeenCalled(); }); }); - - describe('Delete Selected button', () => { - test('it renders the Delete Selected button when onDeleteSelected is provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(true); - }); - - test('it does NOT render the Delete Selected button when onDeleteSelected is NOT provided', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .exists() - ).toBe(false); - }); - - test('it disables the Delete Selected button when the selectedTimelinesCount is 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(true); - }); - - test('it enables the Delete Selected button when the selectedTimelinesCount is greater than 0', () => { - const wrapper = mountWithIntl( - - - - ); - - const props = wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .props() as EuiButtonProps; - - expect(props.isDisabled).toBe(false); - }); - - test('it invokes onDeleteSelected when the Delete Selected button is clicked', () => { - const onDeleteSelected = jest.fn(); - - const wrapper = mountWithIntl( - - - - ); - - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - - expect(onDeleteSelected).toHaveBeenCalled(); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx index a11cbf616adc6..559bbc3eecb82 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/title_row/index.tsx @@ -11,7 +11,7 @@ import * as i18n from '../translations'; import { OpenTimelineProps } from '../types'; import { HeaderSection } from '../../header_section'; -type Props = Pick & { +type Props = Pick & { /** The number of timelines currently selected */ selectedTimelinesCount: number; children?: JSX.Element; @@ -22,41 +22,25 @@ type Props = Pick( - ({ children, onAddTimelinesToFavorites, onDeleteSelected, selectedTimelinesCount, title }) => ( - - {(onAddTimelinesToFavorites || onDeleteSelected) && ( - - {onAddTimelinesToFavorites && ( - - - {i18n.FAVORITE_SELECTED} - - - )} + ({ children, onAddTimelinesToFavorites, selectedTimelinesCount, title }) => ( + + + {onAddTimelinesToFavorites && ( + + + {i18n.FAVORITE_SELECTED} + + + )} - {children && {children}} - - {onDeleteSelected && ( - - - {i18n.DELETE_SELECTED} - - - )} - - )} + {children && {children}} + ) ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index 53663724c76b5..830072675b726 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; +export const ALL_ACTIONS = i18n.translate('xpack.siem.open.timeline.allActionsTooltip', { + defaultMessage: 'All actions', +}); + export const CANCEL = i18n.translate('xpack.siem.open.timeline.cancelButton', { defaultMessage: 'Cancel', }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index b14bb1cf86d31..069e2e60dcc9f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -18,6 +18,7 @@ export interface FavoriteTimelineResult { export interface TimelineResultNote { savedObjectId?: string | null; note?: string | null; + noteId?: string | null; updated?: number | null; updatedBy?: string | null; } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index f310f92ef0c66..7c8926c2064c7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -131,7 +131,7 @@ const RuleActionsOverflowComponent = ({ { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index 40431828d03db..c5a26f1751691 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -8,7 +8,6 @@ import ApolloClient from 'apollo-client'; import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; -import { EuiButton } from '@elastic/eui'; import { HeaderPage } from '../../components/header_page'; import { StatefulOpenTimeline } from '../../components/open_timeline'; import { WrapperPage } from '../../components/wrapper_page'; @@ -47,13 +46,13 @@ const TimelinesPageComponent: React.FC = ({ apolloClient }) => { /> - - {'Import Timeline'} - + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + } */} diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts index 5426ccbdb4f9a..723d164ad2cdd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/translations.ts @@ -16,3 +16,10 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( defaultMessage: 'All timelines', } ); + +export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( + 'xpack.siem.timelines.allTimelines.importTimelineTitle', + { + defaultMessage: 'Import Timeline', + } +); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 990697dd2ae39..4bcd3d27a66fc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -5,9 +5,14 @@ */ import { set as _set } from 'lodash/fp'; -import { SavedObjectsClient, IRouter } from '../../../../../../../../src/core/server'; -import { LegacyServices, LegacyRequest } from '../../../types'; -import { ExportTimelineResults, ExportTimelineRequestParams } from '../types'; +import { + SavedObjectsClient, + IRouter, + KibanaRequest, + SavedObjectsBulkResponse, +} from '../../../../../../../../src/core/server'; +import { LegacyServices } from '../../../types'; +import { ExportTimelineRequestParams, TimelineSavedObject } from '../types'; import { timelineSavedObjectType, noteSavedObjectType } from '../../../saved_objects'; import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; @@ -25,75 +30,135 @@ import { exportTimelinesSchema, exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; +import { NoteSavedObject } from '../../note/types'; + +type ExportTimelineRequest = KibanaRequest< + unknown, + ExportTimelineRequestParams['query'], + ExportTimelineRequestParams['body'], + 'post' +>; + +type ExportTimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; + +type GlobalNotes = Array>; +type EventNotes = NoteSavedObject[]; + +interface ExportedNotes { + eventNotes: EventNotes; + globalNotes: GlobalNotes; +} + +type ExportedTimelines = TimelineSavedObject & + ExportedNotes & { + pinnedEventIds: string[]; + }; + +interface BulkGetInput { + type: string; + id: string; +} + +const getExportTimelineByObjectIds = async ({ + client, + request, +}: { + client: ExportTimelineSavedObjectsClient; + request: ExportTimelineRequest; +}) => { + const timeline = await getTimelinesFromObjects(client, request); + return transformRulesToNdjson(timeline); +}; -const getNotesByTimelineId = (notes, timelineId) => { - const initialNotes = { +const getNotesByTimelineId = (notes: NoteSavedObject[] | undefined, timelineId: string) => { + const initialNotes: ExportedNotes = { eventNotes: [], globalNotes: [], }; - if (notes == null) return initialNotes; - const notesByTimelineId = notes?.filter(note => note.timelineId !== timelineId); - return notesByTimelineId.reduce((acc, curr) => { - if (curr.eventId == null) - return { - ...acc, - globalNotes: [...acc.globalNotes, curr], - }; - else - return { - ...acc, - eventNotes: [...acc.eventNotes, curr], - }; - }, initialNotes); -}; -const getExportTimelineByObjectIds = async ({ client, request }) => { - const timeline = await getTimelinesFromObjects(client, request); - - const timelinesNdjson = transformRulesToNdjson(timeline); - return { timelinesNdjson }; + return ( + notes?.reduce((acc, note) => { + if (note.timelineId === timelineId) { + if (note.eventId == null) + return { + ...acc, + globalNotes: [...acc.globalNotes, note], + }; + else + return { + ...acc, + eventNotes: [...acc.eventNotes, note], + }; + } else return acc; + }, initialNotes) ?? initialNotes + ); }; const getTimelinesFromObjects = async ( - client: SavedObjectsClient, - request: ExportTimelineRequest & LegacyRequest -): Promise => { - const bulkGetTimelines = request.body.objects.map(item => ({ + client: ExportTimelineSavedObjectsClient, + request: ExportTimelineRequest +): Promise => { + const bulkGetTimelines = request.body.objects.map((item): { type: string; id: string } => ({ id: item.timelineId, type: timelineSavedObjectType, })); + const bulkGetNotes = request.body.objects.reduce((acc, item) => { return item.noteIds.length > 0 ? [ ...acc, - ...item.noteIds?.map(noteId => ({ - id: noteId, - type: noteSavedObjectType, - })), + ...item.noteIds?.map( + (noteId): BulkGetInput => ({ + id: noteId, + type: noteSavedObjectType, + }) + ), ] : acc; - }, []); - - const savedObjects = await Promise.all([ - bulkGetTimelines.length > 0 ? client.bulkGet(bulkGetTimelines) : Promise.resolve({}), - bulkGetNotes.length > 0 ? client.bulkGet(bulkGetNotes) : Promise.resolve({}), + }, [] as BulkGetInput[]); + + const savedObjects: [ + SavedObjectsBulkResponse | null, + SavedObjectsBulkResponse | null + ] = await Promise.all([ + bulkGetTimelines.length > 0 ? client.bulkGet(bulkGetTimelines) : Promise.resolve(null), + bulkGetNotes.length > 0 ? client.bulkGet(bulkGetNotes) : Promise.resolve(null), ]); - const timelineObjects = savedObjects[0].saved_objects.map(savedObject => + const timelineObjects: + | TimelineSavedObject[] + | undefined = savedObjects[0]?.saved_objects.map((savedObject: unknown) => convertSavedObjectToSavedTimeline(savedObject) ); - const noteObjects = savedObjects[1]?.saved_objects?.map(savedObject => + const noteObjects: + | NoteSavedObject[] + | undefined = savedObjects[1]?.saved_objects?.map((savedObject: unknown) => convertSavedObjectToSavedNote(savedObject) ); - return timelineObjects.map((timeline, index) => { - return { - ...timeline, - ...getNotesByTimelineId(noteObjects, timeline.savedObjectId), - pinEventsIds: request.body.objects.find(item => item.timelineId === timeline.savedObjectId) - ?.pinnedEventIds, - }; - }); + + return ( + timelineObjects?.map(timeline => { + return { + ...timeline, + ...getNotesByTimelineId(noteObjects, timeline.savedObjectId), + pinnedEventIds: + request.body.objects.find(item => item.timelineId === timeline.savedObjectId) + ?.pinnedEventIds ?? [], + }; + }) ?? [] + ); }; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { @@ -126,13 +191,12 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co body: `Can't export more than ${exportSizeLimit} rules`, }); } - const exported = await getExportTimelineByObjectIds({ + + const responseBody = await getExportTimelineByObjectIds({ client: savedObjectsClient, request, }); - const responseBody = exported.timelinesNdjson; - return response.ok({ headers: { 'Content-Disposition': `attachment; filename="${request.query.file_name}"`, diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index ec8378b66af13..45a48b28b95e3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -6,7 +6,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ -export const timelineId = Joi.string(); +export const timelineId = Joi.string().required(); export const pinnedEventIds = Joi.array().items(Joi.string()); export const noteIds = Joi.array().items(Joi.string()); export const objects = Joi.array().items( @@ -14,7 +14,7 @@ export const objects = Joi.array().items( timelineId, pinnedEventIds, noteIds, - }) + }).required() ); export const exclude_export_details = Joi.boolean(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index a87f66f684faf..3224cf14ee870 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -11,8 +11,6 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; import { NoteSavedObjectToReturnRuntimeType } from '../note/types'; import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; -import { LegacyRequest } from '../../types'; -import { ColumnHeaderResult, Maybe, DataProviderResult, NoteResult } from '../../graphql/types'; /* * ColumnHeader Types @@ -203,29 +201,9 @@ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} export interface ExportTimelineRequestParams { - body: { objects: string[] | null | undefined }; + body: { objects: Array<{ timelineId: string; noteIds: string[]; pinnedEventIds: string[] }> }; query: { file_name: string; exclude_export_details: boolean; }; } - -export interface ExportTimelineResults { - timeline: Array>; -} - -export interface ExportTimelineResult { - columns?: Maybe; - - dataProviders?: Maybe; - - description?: Maybe; - - eventNotes?: Maybe; - - globalNotes?: Maybe; - - pinnedEventIds?: Maybe; - - title?: Maybe; -} From ea7873605358f1f64899efc284da6d25c1cd16d7 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 10 Mar 2020 20:10:31 +0000 Subject: [PATCH 08/37] add export timeline to tables action --- .../__snapshots__/index.test.tsx.snap | 3 + .../generic_downloader}/index.test.tsx | 2 +- .../generic_downloader}/index.tsx | 13 +- .../generic_downloader}/translations.ts | 0 .../delete_timeline_modal/index.tsx | 83 ++++++++++--- .../export_timeline/export_timeline.tsx | 65 ++++++++++ .../open_timeline/export_timeline/index.ts | 64 ++++++++++ .../open_timeline/open_timeline.tsx | 82 +++---------- .../timelines_table/actions_columns.tsx | 114 +++++++----------- .../timelines_table/icon_header_columns.tsx | 60 +-------- .../open_timeline/timelines_table/index.tsx | 15 ++- .../components/open_timeline/translations.ts | 10 +- .../public/components/open_timeline/types.ts | 13 +- .../detection_engine/rules/all/index.tsx | 4 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../rule_actions_overflow/index.tsx | 4 +- .../__snapshots__/index.test.tsx.snap | 3 - 17 files changed, 310 insertions(+), 227 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/index.test.tsx (93%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/index.tsx (88%) rename x-pack/legacy/plugins/siem/public/{pages/detection_engine/rules/components/rule_downloader => components/generic_downloader}/translations.ts (100%) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts delete mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000000..219be8cbda311 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/__snapshots__/index.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GenericDownloader renders correctly against snapshot 1`] = ``; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx similarity index 93% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx index 6306260dfc872..4768c81bbb8f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx @@ -8,7 +8,7 @@ import { shallow } from 'enzyme'; import React from 'react'; import { RuleDownloaderComponent } from './index'; -describe('RuleDownloader', () => { +describe('GenericDownloader', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx similarity index 88% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index f1c0234244737..5f4e9f677df77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -7,13 +7,10 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; import { isFunction, isNil } from 'lodash/fp'; -import { - exportRules, - ExportDocumentsProps, -} from '../../../../../containers/detection_engine/rules'; -import { displayErrorToast, useStateToaster } from '../../../../../components/toasters'; +import { exportRules, ExportDocumentsProps } from '../../containers/detection_engine/rules'; +import { displayErrorToast, useStateToaster } from '../toasters'; import * as i18n from './translations'; -import { ExportTimelineIds } from '../../../../../components/open_timeline/open_timeline'; +import { ExportTimelineIds } from '../open_timeline/open_timeline'; const InvisibleAnchor = styled.a` display: none; @@ -114,6 +111,6 @@ export const RuleDownloaderComponent = ({ RuleDownloaderComponent.displayName = 'RuleDownloaderComponent'; -export const RuleDownloader = React.memo(RuleDownloaderComponent); +export const GenericDownloader = React.memo(RuleDownloaderComponent); -RuleDownloader.displayName = 'RuleDownloader'; +GenericDownloader.displayName = 'GenericDownloader'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts b/x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts similarity index 100% rename from x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/translations.ts rename to x-pack/legacy/plugins/siem/public/components/generic_downloader/translations.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index caa9cd0689c76..c10e6d51dcf2e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -4,13 +4,69 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiModal, EuiToolTip, EuiOverlayMask } from '@elastic/eui'; +import { EuiModal, EuiOverlayMask, EuiIcon, EuiLinkProps } from '@elastic/eui'; import React, { useState } from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; +import classNames from 'classnames'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; import * as i18n from '../translations'; import { DeleteTimelines } from '../types'; +const RemovePopover = createGlobalStyle` +div.euiPopover__panel-isOpen { + display: none; +} +`; + +export const CustomLink = React.forwardRef( + ( + { + children, + color = 'primary', + className, + href, + external, + target, + rel, + type = 'button', + onClick, + disabled, + ...rest + }, + ref + ) => { + const anchorProps = { + className: classNames('euiLink', disabled ? 'euiLink-disabled' : 'euiLink--text', className), + href, + target, + onClick, + ...rest, + }; + + return !disabled ? ( + } {...anchorProps}> + {children} + + ) : ( + {children} + ); + } +); + +CustomLink.displayName = 'CustomLink'; + +export const TimelineCustomAction = styled(CustomLink)` + width: 100%; + .euiButtonEmpty__content a { + justify-content: flex-start; + } +`; + +export const ActionListIcon = styled(EuiIcon)` + margin-right: 8px; +`; + interface Props { deleteTimelines?: DeleteTimelines; savedObjectId?: string | null; @@ -35,19 +91,18 @@ export const DeleteTimelineModalButton = React.memo( return ( <> - - - - + {showModal && } + + <> + + {i18n.DELETE_SELECTED} + + {showModal ? ( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx new file mode 100644 index 0000000000000..f93c319e6b014 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import uuid from 'uuid'; +import { OpenTimelineResult } from '../types'; +import { GenericDownloader } from '../../generic_downloader'; +import * as i18n from '../translations'; +import { ActionListIcon, TimelineCustomAction } from '../delete_timeline_modal'; +import { useExportTimeline } from '.'; +import { useStateToaster } from '../../toasters'; + +const ExportTimeline: React.FC<{ + selectedTimelines: OpenTimelineResult[] | undefined; + onDownloadComplete?: () => void; +}> = ({ selectedTimelines, onDownloadComplete }) => { + const { enableDownloader, setEnableDownloader, exportedIds, getExportedData } = useExportTimeline( + selectedTimelines + ); + const [, dispatchToaster] = useStateToaster(); + + return ( + <> + {selectedTimelines != null && exportedIds != null && enableDownloader && ( + { + setEnableDownloader(false); + if (typeof onDownloadComplete === 'function') onDownloadComplete(); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }} + /> + )} + { + setEnableDownloader(true); + }} + > + <> + + {i18n.EXPORT_SELECTED} + + + + ); +}; +ExportTimeline.displayName = 'ExportTimeline'; + +export const TimelineDownloader = React.memo(ExportTimeline); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts new file mode 100644 index 0000000000000..6335094569e8b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { keys } from 'lodash/fp'; +import { OpenTimelineResult } from '../types'; +import { KibanaServices } from '../../../lib/kibana'; +import { throwIfNotOk } from '../../../hooks/api/api'; +import { ExportSelectedData } from '../../generic_downloader'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const useExportTimeline = (selectedItems: OpenTimelineResult[] | undefined) => { + const [enableDownloader, setEnableDownloader] = useState(false); + + const exportSelectedTimeline: ExportSelectedData = useCallback( + async ({ + excludeExportDetails = false, + filename = `timelines_export.ndjson`, + ids = [], + signal, + }): Promise => { + const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; + const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { + method: 'POST', + body, + query: { + exclude_export_details: excludeExportDetails, + file_name: filename, + }, + signal, + asResponse: true, + }); + + await throwIfNotOk(response.response); + return response.body!; + }, + [] + ); + + const getExportedIds = useCallback( + (selectedTimelines: OpenTimelineResult[]) => { + return selectedTimelines.map(item => ({ + timelineId: item.savedObjectId, + pinnedEventIds: + item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, + noteIds: item?.notes?.reduce( + (acc, note) => (note.noteId != null ? [...acc, note.noteId] : acc), + [] as string[] + ), + })); + }, + [selectedItems] + ); + + return { + enableDownloader, + setEnableDownloader, + exportedIds: selectedItems != null ? getExportedIds(selectedItems) : undefined, + getExportedData: exportSelectedTimeline, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 1754b132d53da..3c99836234dde 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -5,10 +5,9 @@ */ import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import React, { useMemo, useCallback, useState } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import uuid from 'uuid'; -import { keys } from 'lodash/fp'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; @@ -30,15 +29,9 @@ import { EXPORT_FILENAME, } from '../../pages/detection_engine/rules/translations'; import { useStateToaster } from '../toasters'; -import { - RuleDownloader, - ExportSelectedData, -} from '../../pages/detection_engine/rules/components/rule_downloader'; - -import { TIMELINE_EXPORT_URL } from '../../../common/constants'; -import { throwIfNotOk } from '../../hooks/api/api'; -import { KibanaServices } from '../../lib/kibana'; - +import { GenericDownloader } from '../generic_downloader'; +import { useExportTimeline } from './export_timeline'; +import { TimelineDownloader } from './export_timeline/export_timeline'; export interface ExportTimelineIds { timelineId: string | null | undefined; pinnedEventIds: string[] | null | undefined; @@ -71,7 +64,6 @@ export const OpenTimeline = React.memo( totalSearchResultsCount, }) => { const [, dispatchToaster] = useStateToaster(); - const [enableDownloader, setEnableDownloader] = useState(false); const text = useMemo( () => ( @@ -92,46 +84,21 @@ export const OpenTimeline = React.memo( [totalSearchResultsCount] ); - const exportSelectedTimeline: ExportSelectedData = useCallback( - async ({ - excludeExportDetails = false, - filename = `timelines_export.ndjson`, - ids = [], - signal, - }): Promise => { - const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; - const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { - method: 'POST', - body, - query: { - exclude_export_details: excludeExportDetails, - file_name: filename, - }, - signal, - asResponse: true, - }); - - await throwIfNotOk(response.response); - return response.body!; - }, - [] - ); - const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( { - closePopover(); - - setEnableDownloader(true); - }} + // onClick={() => { + // closePopover(); + // }} > - {BATCH_ACTION_EXPORT_SELECTED} + , ( [selectedItems, dispatchToaster, history] ); - const getSelectedItemsIds: ExportTimelineIds[] = useMemo(() => { - return selectedItems.map(item => ({ - timelineId: item.savedObjectId, - pinnedEventIds: - item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, - noteIds: item?.notes?.reduce( - (acc, note) => (note.noteId != null ? [...acc, note.noteId] : acc), - [] as string[] - ), - })); - }, [selectedItems]); - return ( <> - {enableDownloader && ( - { setEnableDownloader(false); dispatchToaster({ @@ -182,7 +137,8 @@ export const OpenTimeline = React.memo( }); }} /> - )} + )} */} + ( { +}): [TimelineActionsOverflowColumns] => { const openAsDuplicateColumn = { name: i18n.OPEN_AS_DUPLICATE, - icon: , + icon: 'copy', + onClick: ({ savedObjectId }: OpenTimelineResult) => { + onOpenTimeline({ + duplicate: true, + timelineId: savedObjectId ?? '', + }); + }, + description: i18n.OPEN_AS_DUPLICATE, }; - // onClick: () => { - // onOpenTimeline({ - // duplicate: true, - // timelineId: `${timelineResult.savedObjectId}`, - // }); - // }, + const exportTimelineAction = { + name: i18n.EXPORT_SELECTED, + icon: 'exportAction', + render: (selectedTimeline: OpenTimelineResult) => ( + + ), + description: i18n.EXPORT_SELECTED, + }; - // render: (savedObjectId: string, timelineResult: OpenTimelineResult) => ( - // - // - // onOpenTimeline({ - // duplicate: true, - // timelineId: `${timelineResult.savedObjectId}`, - // }) - // } - // size="s" - // /> - // - // ), - // sortable: false, - // width: ACTION_COLUMN_WIDTH, const deleteTimelineColumn = { - name: i18n.DELETE, - icon: , - - // icon: ( - // - // ), - // render: (savedObjectId: string, { title }: OpenTimelineResult) => ( - // - // ), - // sortable: false, - // width: ACTION_COLUMN_WIDTH, + name: i18n.DELETE_SELECTED, + render: ({ savedObjectId, title }: OpenTimelineResult) => ( + + ), + description: i18n.DELETE_SELECTED, }; - const pannelItems = [ - actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, - actionTimelineToShow.includes('delete') && deleteTimelines != null - ? deleteTimelineColumn - : null, - ].filter(action => action != null) as EuiContextMenuPanelItemDescriptor[]; - - return pannelItems.length > 0 - ? [ - { - id: 0, - items: pannelItems, - }, - ] - : undefined; + return [ + { + width: '40px', + actions: [ + actionTimelineToShow.includes('duplicate') ? openAsDuplicateColumn : null, + actionTimelineToShow.includes('export') ? exportTimelineAction : null, + actionTimelineToShow.includes('delete') && deleteTimelines != null + ? deleteTimelineColumn + : null, + ].filter(action => action != null), + }, + ]; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx index 3c48720853a93..5b0f3ded7d71b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -6,57 +6,18 @@ /* eslint-disable react/display-name */ -import { - EuiIcon, - EuiToolTip, - EuiButtonIcon, - EuiPopover, - EuiContextMenu, - EuiContextMenuPanelDescriptor, -} from '@elastic/eui'; -import React, { useState, useCallback } from 'react'; +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import React from 'react'; import { ACTION_COLUMN_WIDTH } from './common_styles'; import { getNotesCount, getPinnedEventCount } from '../helpers'; import * as i18n from '../translations'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; -const EditTimelineActions = React.memo<{ - actionsColumns: EuiContextMenuPanelDescriptor[] | undefined; -}>(({ actionsColumns }) => { - const [isPopoverOpen, setPopover] = useState(false); - const tooglePopover = useCallback( - (newState: boolean) => { - setPopover(newState); - }, - [setPopover] - ); - return ( - - } - isOpen={isPopoverOpen} - closePopover={tooglePopover.bind(null, false)} - > - - - ); -}); - -EditTimelineActions.displayName = 'EditTimelineActions'; /** * Returns the columns that have icon headers */ -export const getIconHeaderColumns = ( - actionsColumns: EuiContextMenuPanelDescriptor[] | undefined -) => [ +export const getIconHeaderColumns = () => [ { align: 'center', field: 'pinnedEventIds', @@ -103,19 +64,4 @@ export const getIconHeaderColumns = ( sortable: false, width: ACTION_COLUMN_WIDTH, }, - { - align: 'center', - field: 'visControls', - name: ( - - - - ), - render: () => { - return ; - // return xxx; - }, - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index c9cc5a3a1b501..683c71992e094 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -50,6 +50,7 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, + exportTimelines, itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, @@ -62,11 +63,6 @@ const getTimelinesTableColumns = ({ onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; }) => { - const actionsColumns = getActionsColumns({ - deleteTimelines, - onOpenTimeline, - actionTimelineToShow, - }); return [ ...getCommonColumns({ itemIdToExpandedNotesRowMap, @@ -74,7 +70,13 @@ const getTimelinesTableColumns = ({ onToggleShowNotes, }), ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(actionsColumns), + ...getIconHeaderColumns(), + ...getActionsColumns({ + deleteTimelines, + exportTimelines, + onOpenTimeline, + actionTimelineToShow, + }), ]; }; @@ -82,6 +84,7 @@ export interface TimelinesTableProps { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; defaultPageSize: number; + exportTimelines?: Dispatch>; loading: boolean; itemIdToExpandedNotesRowMap: Record; onOpenTimeline: OnOpenTimeline; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index 830072675b726..17edaa96f67ab 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -38,6 +38,14 @@ export const EXPAND = i18n.translate('xpack.siem.open.timeline.expandButton', { defaultMessage: 'Expand', }); +export const EXPORT_FILENAME = i18n.translate('xpack.siem.open.timeline.exportFileNameTitle', { + defaultMessage: 'timelines_export', +}); + +export const EXPORT_SELECTED = i18n.translate('xpack.siem.open.timeline.exportSelectedButton', { + defaultMessage: 'Export selected', +}); + export const FAVORITE_SELECTED = i18n.translate('xpack.siem.open.timeline.favoriteSelectedButton', { defaultMessage: 'Favorite selected', }); @@ -70,7 +78,7 @@ export const ONLY_FAVORITES = i18n.translate('xpack.siem.open.timeline.onlyFavor }); export const OPEN_AS_DUPLICATE = i18n.translate('xpack.siem.open.timeline.openAsDuplicateTooltip', { - defaultMessage: 'Open as a duplicate timeline', + defaultMessage: 'Duplicate timeline', }); export const OPEN_TIMELINE = i18n.translate('xpack.siem.open.timeline.openTimelineButton', { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 069e2e60dcc9f..a39b1955b640d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -23,6 +23,17 @@ export interface TimelineResultNote { updatedBy?: string | null; } +export interface TimelineActionsOverflowColumns { + width: string; + actions: Array<{ + name: string; + icon?: string; + onClick?: (timeline: OpenTimelineResult) => void; + description: string; + render?: (timeline: OpenTimelineResult) => JSX.Element; + } | null>; +} + /** The results of the query run by the OpenTimeline component */ export interface OpenTimelineResult { created?: number | null; @@ -93,7 +104,7 @@ export interface OnTableChangeParams { /** Invoked by the EUI table implementation when the user interacts with the table */ export type OnTableChange = (tableChange: OnTableChangeParams) => void; -export type ActionTimelineToShow = 'duplicate' | 'delete' | 'selectable'; +export type ActionTimelineToShow = 'duplicate' | 'delete' | 'export' | 'selectable'; export interface OpenTimelineProps { /** Invoked when the user clicks the delete (trash) icon on an individual timeline */ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 9676b83a26f55..c2f44de5e4c56 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -35,7 +35,7 @@ import { useStateToaster } from '../../../../components/toasters'; import { Loader } from '../../../../components/loader'; import { Panel } from '../../../../components/panel'; import { PrePackagedRulesPrompt } from '../components/pre_packaged_rules/load_empty_prompt'; -import { RuleDownloader } from '../components/rule_downloader'; +import { GenericDownloader } from '../../../../components/generic_downloader'; import { getPrePackagedRuleStatus } from '../helpers'; import * as i18n from '../translations'; import { EuiBasicTableOnChange } from '../types'; @@ -244,7 +244,7 @@ export const AllRules = React.memo( return ( <> - { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index a6de98de9020a..74176aeab0360 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -55,7 +55,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` } /> - - { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap deleted file mode 100644 index 4259b68bf14a2..0000000000000 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_downloader/__snapshots__/index.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RuleDownloader renders correctly against snapshot 1`] = ``; From 36453105ab9e210cfb0adc75d0eb8c4975dfab5e Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 10 Mar 2020 21:23:53 +0000 Subject: [PATCH 09/37] fix types --- .../delete_timeline_modal/index.tsx | 6 ++- .../public/components/open_timeline/index.tsx | 3 +- .../open_timeline/open_timeline.tsx | 50 ++++--------------- .../open_timeline/timelines_table/index.tsx | 3 -- .../components/open_timeline/translations.ts | 8 +++ .../public/components/open_timeline/types.ts | 3 ++ .../public/containers/timeline/all/index.tsx | 4 +- 7 files changed, 31 insertions(+), 46 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index c10e6d51dcf2e..1d011a702eed5 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiModal, EuiOverlayMask, EuiIcon, EuiLinkProps } from '@elastic/eui'; +import { EuiModal, EuiOverlayMask, EuiIcon, EuiLinkAnchorProps } from '@elastic/eui'; import React, { useState } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; @@ -19,7 +19,9 @@ div.euiPopover__panel-isOpen { } `; -export const CustomLink = React.forwardRef( +type CustomLinkType = EuiLinkAnchorProps & { disabled: boolean }; + +export const CustomLink = React.forwardRef( ( { children, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx index 26a7487fee52b..6d00edf28a88f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.tsx @@ -256,7 +256,7 @@ export const StatefulOpenTimelineComponent = React.memo( sort={{ sortField: sortField as SortFieldTimeline, sortOrder: sortDirection as Direction }} onlyUserFavorite={onlyFavorites} > - {({ timelines, loading, totalCount }) => { + {({ timelines, loading, totalCount, refetch }) => { return !isModal ? ( ( pageIndex={pageIndex} pageSize={pageSize} query={search} + refetch={refetch} searchResults={timelines} selectedItems={selectedItems} sortDirection={sortDirection} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 3c99836234dde..3913c35290505 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -7,7 +7,6 @@ import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; import React, { useMemo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import uuid from 'uuid'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; @@ -22,15 +21,7 @@ import { } from '../detection_engine/utility_bar'; import * as i18n from './translations'; -import { - BATCH_ACTIONS, - BATCH_ACTION_EXPORT_SELECTED, - REFRESH, - EXPORT_FILENAME, -} from '../../pages/detection_engine/rules/translations'; import { useStateToaster } from '../toasters'; -import { GenericDownloader } from '../generic_downloader'; -import { useExportTimeline } from './export_timeline'; import { TimelineDownloader } from './export_timeline/export_timeline'; export interface ExportTimelineIds { timelineId: string | null | undefined; @@ -56,6 +47,7 @@ export const OpenTimeline = React.memo( pageIndex, pageSize, query, + refetch, searchResults, selectedItems, sortDirection, @@ -88,13 +80,7 @@ export const OpenTimeline = React.memo( (closePopover: () => void) => ( { - // closePopover(); - // }} - > + ( return ( <> - {/* {enableDownloader && ( - { - setEnableDownloader(false); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} - /> - )} */} - ( iconType="arrowDown" popoverContent={getBatchItemsPopoverContent} > - {BATCH_ACTIONS} + {i18n.BATCH_ACTIONS} - null}> - {REFRESH} + { + if (typeof refetch === 'function') refetch(); + }} + > + {i18n.REFRESH} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index 683c71992e094..039643a34f400 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -50,7 +50,6 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, - exportTimelines, itemIdToExpandedNotesRowMap, onOpenTimeline, onToggleShowNotes, @@ -73,7 +72,6 @@ const getTimelinesTableColumns = ({ ...getIconHeaderColumns(), ...getActionsColumns({ deleteTimelines, - exportTimelines, onOpenTimeline, actionTimelineToShow, }), @@ -84,7 +82,6 @@ export interface TimelinesTableProps { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; defaultPageSize: number; - exportTimelines?: Dispatch>; loading: boolean; itemIdToExpandedNotesRowMap: Record; onOpenTimeline: OnOpenTimeline; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index 17edaa96f67ab..d2b48d9cbff2e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -10,6 +10,10 @@ export const ALL_ACTIONS = i18n.translate('xpack.siem.open.timeline.allActionsTo defaultMessage: 'All actions', }); +export const BATCH_ACTIONS = i18n.translate('xpack.siem.open.timeline.batchActionsTitle', { + defaultMessage: 'Bulk actions', +}); + export const CANCEL = i18n.translate('xpack.siem.open.timeline.cancelButton', { defaultMessage: 'Cancel', }); @@ -97,6 +101,10 @@ export const POSTED = i18n.translate('xpack.siem.open.timeline.postedLabel', { defaultMessage: 'Posted:', }); +export const REFRESH = i18n.translate('xpack.siem.open.timeline.refreshTitle', { + defaultMessage: 'Refresh', +}); + export const SEARCH_PLACEHOLDER = i18n.translate('xpack.siem.open.timeline.searchPlaceholder', { defaultMessage: 'e.g. timeline name, or description', }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index a39b1955b640d..57f37734af7ad 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -7,6 +7,7 @@ import { AllTimelinesVariables } from '../../containers/timeline/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../graphql/types'; +import { Refetch } from '../../store/inputs/model'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -139,6 +140,8 @@ export interface OpenTimelineProps { pageSize: number; /** The currently applied search criteria */ query: string; + /** Refetch timelines data */ + refetch?: Refetch; /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index 22c7b03f34dd5..f77752ab14048 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -23,6 +23,7 @@ export interface AllTimelinesArgs { timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; + refetch: () => void; } export interface AllTimelinesVariables { @@ -91,9 +92,10 @@ const AllTimelinesQueryComponent: React.FC = ({ notifyOnNetworkStatusChange variables={variables} > - {({ data, loading }) => + {({ data, loading, refetch }) => children!({ loading, + refetch, totalCount: getOr(0, 'getAllTimeline.totalCount', data), timelines: getAllTimeline( JSON.stringify(variables), From d6eec2b9b106a9b9c58152a954d7517ebc6ec7c3 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 11 Mar 2020 01:01:25 +0000 Subject: [PATCH 10/37] add client side unit test --- .../export_timeline/export_timeline.test.tsx | 101 ++++++++++++++++++ .../export_timeline/export_timeline.tsx | 8 +- .../export_timeline/index.test.tsx | 70 ++++++++++++ .../export_timeline/{index.ts => index.tsx} | 21 +++- .../open_timeline/export_timeline/mocks.ts | 99 +++++++++++++++++ 5 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx rename x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/{index.ts => index.tsx} (78%) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx new file mode 100644 index 0000000000000..3c12b774ce0fd --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { TimelineDownloader } from './export_timeline'; +import { mockSelectedTimeline } from './mocks'; +import { ReactWrapper, mount } from 'enzyme'; +import { useExportTimeline } from '.'; + +jest.mock('../translations', () => { + return { + EXPORT_SELECTED: 'EXPORT_SELECTED', + EXPORT_FILENAME: 'TIMELINE', + }; +}); + +jest.mock('.', () => { + return { + useExportTimeline: jest.fn(), + }; +}); + +describe('TimelineDownloader', () => { + let wrapper: ReactWrapper; + describe('render without selected timeline', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: false, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + wrapper = mount(); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('Should render title', () => { + expect(wrapper.text()).toEqual('EXPORT_SELECTED'); + }); + + test('should render exportIcon', () => { + expect(wrapper.find('[data-test-subj="export-timeline-icon"]').exists()).toBeTruthy(); + }); + + test('should not be clickable', () => { + expect( + wrapper + .find('[data-test-subj="export-timeline"]') + .first() + .prop('disabled') + ).toBeTruthy(); + }); + + test('should not render a downloader', () => { + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); + }); + }); + + describe('render with a selected timeline', () => { + beforeAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ + enableDownloader: true, + setEnableDownloader: jest.fn(), + exportedIds: {}, + getExportedData: jest.fn(), + }); + wrapper = mount(); + }); + + afterAll(() => { + ((useExportTimeline as unknown) as jest.Mock).mockReset(); + }); + + test('Should render title', () => { + expect(wrapper.text()).toEqual('EXPORT_SELECTED'); + }); + + test('should render exportIcon', () => { + expect(wrapper.find('[data-test-subj="export-timeline-icon"]').exists()).toBeTruthy(); + }); + + test('should be clickable', () => { + expect( + wrapper + .find('[data-test-subj="export-timeline"]') + .first() + .prop('disabled') + ).toBeFalsy(); + }); + + test('should render a downloader', () => { + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx index f93c319e6b014..50ef94e43a225 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -21,14 +21,14 @@ const ExportTimeline: React.FC<{ selectedTimelines ); const [, dispatchToaster] = useStateToaster(); - return ( <> {selectedTimelines != null && exportedIds != null && enableDownloader && ( { setEnableDownloader(false); if (typeof onDownloadComplete === 'function') onDownloadComplete(); @@ -51,9 +51,10 @@ const ExportTimeline: React.FC<{ onClick={() => { setEnableDownloader(true); }} + data-test-subj="export-timeline" > <> - + {i18n.EXPORT_SELECTED} @@ -61,5 +62,4 @@ const ExportTimeline: React.FC<{ ); }; ExportTimeline.displayName = 'ExportTimeline'; - export const TimelineDownloader = React.memo(ExportTimeline); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx new file mode 100644 index 0000000000000..3ad07a0a1ff3e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mockSelectedTimeline } from './mocks'; +import { ReactWrapper, mount } from 'enzyme'; +import { useExportTimeline, ExportTimeline } from '.'; +import { get } from 'lodash/fp'; + +describe('useExportTimeline', () => { + describe('call with selected timelines', () => { + let wrapper: ReactWrapper; + let exportTimelineRes: ExportTimeline; + const TestHook = () => { + exportTimelineRes = useExportTimeline(mockSelectedTimeline); + return
; + }; + + beforeAll(() => { + wrapper = mount(); + }); + + test('Downloader should be disabled by default', () => { + expect(exportTimelineRes.enableDownloader).toBeFalsy(); + }); + + test('exportedIds should include timelineId', () => { + expect(get('exportedIds[0].timelineId', exportTimelineRes)).toEqual( + mockSelectedTimeline[0].savedObjectId + ); + }); + + test('exportedIds should include noteIds', () => { + expect(get('exportedIds[0].noteIds', exportTimelineRes)).toEqual([ + mockSelectedTimeline[0].notes[0].noteId, + mockSelectedTimeline[0].notes[1].noteId, + ]); + }); + + test('exportedIds should include pinnedEventIds', () => { + expect(get('exportedIds[0].pinnedEventIds', exportTimelineRes)).toEqual( + Object.keys(mockSelectedTimeline[0].pinnedEventIds) + ); + }); + }); + + describe('call without selected timelines', () => { + let wrapper: ReactWrapper; + let exportTimelineRes: ExportTimeline; + const TestHook = () => { + exportTimelineRes = useExportTimeline(undefined); + return
; + }; + + beforeAll(() => { + wrapper = mount(); + }); + + test('should contain exportedIds', () => { + expect(exportTimelineRes?.hasOwnProperty('exportedIds')).toBeTruthy(); + }); + + test('should have no exportedIds', () => { + expect(exportTimelineRes.exportedIds).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx similarity index 78% rename from x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts rename to x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 6335094569e8b..bbbb0aa52a222 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, Dispatch, SetStateAction } from 'react'; import { keys } from 'lodash/fp'; import { OpenTimelineResult } from '../types'; import { KibanaServices } from '../../../lib/kibana'; @@ -12,7 +12,22 @@ import { throwIfNotOk } from '../../../hooks/api/api'; import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; -export const useExportTimeline = (selectedItems: OpenTimelineResult[] | undefined) => { +export interface ExportTimeline { + enableDownloader: boolean; + setEnableDownloader: Dispatch>; + exportedIds: + | Array<{ + timelineId: string | null | undefined; + pinnedEventIds: string[] | null | undefined; + noteIds: string[] | undefined; + }> + | undefined; + getExportedData: ExportSelectedData; +} + +export const useExportTimeline = ( + selectedItems: OpenTimelineResult[] | undefined +): ExportTimeline => { const [enableDownloader, setEnableDownloader] = useState(false); const exportSelectedTimeline: ExportSelectedData = useCallback( @@ -37,7 +52,7 @@ export const useExportTimeline = (selectedItems: OpenTimelineResult[] | undefine await throwIfNotOk(response.response); return response.body!; }, - [] + [selectedItems] ); const getExportedIds = useCallback( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts new file mode 100644 index 0000000000000..34d763839003c --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/mocks.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const mockSelectedTimeline = [ + { + savedObjectId: 'baa20980-6301-11ea-9223-95b6d4dd806c', + version: 'WzExNzAsMV0=', + columns: [ + { + columnHeaderType: 'not-filtered', + indexes: null, + id: '@timestamp', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'message', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.category', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'event.action', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'host.name', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'source.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'destination.ip', + name: null, + searchable: null, + }, + { + columnHeaderType: 'not-filtered', + indexes: null, + id: 'user.name', + name: null, + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'duplicate timeline', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1583866966262, + createdBy: 'elastic', + updated: 1583866966262, + updatedBy: 'elastic', + notes: [ + { + noteId: 'noteIdOne', + }, + { + noteId: 'noteIdTwo', + }, + ], + pinnedEventIds: { '23D_e3ABGy2SlgJPuyEh': true, eHD_e3ABGy2SlgJPsh4u: true }, + }, +]; From a94d6f65f56032530ea9647cfc7f95111c3d0278 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 11 Mar 2020 15:21:48 +0000 Subject: [PATCH 11/37] add server-side unit test --- .../export_timeline/index.test.tsx | 8 +- .../routes/__mocks__/request_responses.ts | 453 ++++++++++++++++++ .../routes/export_timelines_route.test.ts | 88 ++++ .../timeline/routes/export_timelines_route.ts | 81 +--- .../plugins/siem/server/lib/timeline/types.ts | 41 +- 5 files changed, 605 insertions(+), 66 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx index 3ad07a0a1ff3e..057f9c435df91 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -6,13 +6,12 @@ import React from 'react'; import { mockSelectedTimeline } from './mocks'; -import { ReactWrapper, mount } from 'enzyme'; +import { mount } from 'enzyme'; import { useExportTimeline, ExportTimeline } from '.'; import { get } from 'lodash/fp'; describe('useExportTimeline', () => { describe('call with selected timelines', () => { - let wrapper: ReactWrapper; let exportTimelineRes: ExportTimeline; const TestHook = () => { exportTimelineRes = useExportTimeline(mockSelectedTimeline); @@ -20,7 +19,7 @@ describe('useExportTimeline', () => { }; beforeAll(() => { - wrapper = mount(); + mount(); }); test('Downloader should be disabled by default', () => { @@ -48,7 +47,6 @@ describe('useExportTimeline', () => { }); describe('call without selected timelines', () => { - let wrapper: ReactWrapper; let exportTimelineRes: ExportTimeline; const TestHook = () => { exportTimelineRes = useExportTimeline(undefined); @@ -56,7 +54,7 @@ describe('useExportTimeline', () => { }; beforeAll(() => { - wrapper = mount(); + mount(); }); test('should contain exportedIds', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts new file mode 100644 index 0000000000000..a5ac37249490f --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -0,0 +1,453 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TIMELINE_EXPORT_URL } from '../../../../../common/constants'; +import { requestMock } from '../../../detection_engine/routes/__mocks__'; + +export const getExportTimelinesRequest = () => + requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { + objects: [ + { + timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + noteIds: ['eb3f3930-61dc-11ea-8a49-e77254c5b742'], + pinnedEventIds: [], + }, + { + timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + noteIds: ['706e7510-5d52-11ea-8f07-0392944939c1'], + pinnedEventIds: ['6HW_eHABMQha2n6bHvQ0'], + }, + ], + }, + }); + +export const mockTimelinesSavedObjects = () => ({ + saved_objects: [ + { + id: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockTimelines = () => ({ + saved_objects: [ + { + savedObjectId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + version: 'Wzk0OSwxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with a global note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + }, + }, + title: 'test no.2', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582625382448, + createdBy: 'elastic', + updated: 1583741197521, + updatedBy: 'elastic', + }, + { + savedObjectId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + version: 'Wzk0NywxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'message', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.category', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'source.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'user.name', + searchable: null, + }, + ], + dataProviders: [], + description: 'with an event note', + eventType: 'raw', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + serializedQuery: + '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', + kuery: { expression: 'zeek.files.sha1 : * ', kind: 'kuery' }, + }, + }, + title: 'test no.3', + dateRange: { start: 1582538951145, end: 1582625351145 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1582642817439, + createdBy: 'elastic', + updated: 1583741175216, + updatedBy: 'elastic', + }, + ], +}); + +export const mockNotesSavedObjects = () => ({ + saved_objects: [ + { + id: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + type: 'fakeType', + attributes: {}, + references: [], + }, + { + id: '706e7510-5d52-11ea-8f07-0392944939c1', + type: 'fakeType', + attributes: {}, + references: [], + }, + ], +}); + +export const mockNotes = () => ({ + saved_objects: [ + { + noteId: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', + version: 'Wzk1MCwxXQ==', + note: 'Global note', + timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', + created: 1583741205473, + createdBy: 'elastic', + updated: 1583741205473, + updatedBy: 'elastic', + }, + { + noteId: '706e7510-5d52-11ea-8f07-0392944939c1', + version: 'WzEwMiwxXQ==', + eventId: '6HW_eHABMQha2n6bHvQ0', + note: 'this is a note!!', + timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', + created: 1583241924223, + createdBy: 'elastic', + updated: 1583241924223, + updatedBy: 'elastic', + }, + ], +}); + +// export const getExportTimelines = () => [ +// { +// savedObjectId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', +// version: 'Wzk0OSwxXQ==', +// columns: [ +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: '@timestamp', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'message', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'event.category', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'event.action', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'host.name', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'source.ip', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'destination.ip', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'user.name', +// searchable: null, +// }, +// ], +// dataProviders: [], +// description: 'with a global note', +// eventType: 'raw', +// filters: [], +// kqlMode: 'filter', +// kqlQuery: { +// filterQuery: { +// kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, +// serializedQuery: +// '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', +// }, +// }, +// title: 'test no.2', +// dateRange: { start: 1582538951145, end: 1582625351145 }, +// savedQueryId: null, +// sort: { columnId: '@timestamp', sortDirection: 'desc' }, +// created: 1582625382448, +// createdBy: 'elastic', +// updated: 1583741197521, +// updatedBy: 'elastic', +// eventNotes: [], +// globalNotes: [ +// { +// noteId: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', +// version: 'Wzk1MCwxXQ==', +// note: 'Global note', +// timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', +// created: 1583741205473, +// createdBy: 'elastic', +// updated: 1583741205473, +// updatedBy: 'elastic', +// }, +// ], +// pinnedEventIds: [], +// }, +// { +// savedObjectId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', +// version: 'Wzk0NywxXQ==', +// columns: [ +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: '@timestamp', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'message', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'event.category', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'event.action', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'host.name', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'source.ip', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'destination.ip', +// searchable: null, +// }, +// { +// indexes: null, +// name: null, +// columnHeaderType: 'not-filtered', +// id: 'user.name', +// searchable: null, +// }, +// ], +// dataProviders: [], +// description: 'with an event note', +// eventType: 'raw', +// filters: [], +// kqlMode: 'filter', +// kqlQuery: { +// filterQuery: { +// serializedQuery: +// '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', +// kuery: { expression: 'zeek.files.sha1 : * ', kind: 'kuery' }, +// }, +// }, +// title: 'test no.3', +// dateRange: { start: 1582538951145, end: 1582625351145 }, +// savedQueryId: null, +// sort: { columnId: '@timestamp', sortDirection: 'desc' }, +// created: 1582642817439, +// createdBy: 'elastic', +// updated: 1583741175216, +// updatedBy: 'elastic', +// eventNotes: [ +// { +// noteId: '706e7510-5d52-11ea-8f07-0392944939c1', +// version: 'WzEwMiwxXQ==', +// eventId: '6HW_eHABMQha2n6bHvQ0', +// note: 'this is a note!!', +// timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', +// created: 1583241924223, +// createdBy: 'elastic', +// updated: 1583241924223, +// updatedBy: 'elastic', +// }, +// ], +// globalNotes: [], +// pinnedEventIds: ['6HW_eHABMQha2n6bHvQ0'], +// }, +// ]; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts new file mode 100644 index 0000000000000..c764afe5b4a54 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + mockTimelines, + mockNotes, + mockTimelinesSavedObjects, + mockNotesSavedObjects, + getExportTimelinesRequest, +} from './__mocks__/request_responses'; +import { exportTimelinesRoute } from './export_timelines_route'; +import { + serverMock, + requestContextMock, + requestMock, +} from '../../detection_engine/routes/__mocks__'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +jest.mock('../convert_saved_object_to_savedtimeline', () => { + return { + convertSavedObjectToSavedTimeline: jest.fn(), + }; +}); + +jest.mock('../../note/saved_object', () => { + return { + convertSavedObjectToSavedNote: jest.fn(), + }; +}); +describe('export timelines', () => { + let server: ReturnType; + let { clients, context } = requestContextMock.createTools(); + const config = jest.fn().mockImplementation(() => { + return { + get: () => { + return 100; + }, + has: jest.fn(), + }; + }); + + beforeEach(() => { + server = serverMock.create(); + ({ clients, context } = requestContextMock.createTools()); + + clients.savedObjectsClient.bulkGet + .mockResolvedValueOnce(mockTimelinesSavedObjects()) + .mockResolvedValueOnce(mockNotesSavedObjects()); // successful status search + ((convertSavedObjectToSavedTimeline as unknown) as jest.Mock).mockReturnValue(mockTimelines()); + ((convertSavedObjectToSavedNote as unknown) as jest.Mock).mockReturnValue(mockNotes()); + exportTimelinesRoute(server.router, config); + }); + + describe('status codes', () => { + test('returns 200 when finding selected timelines', async () => { + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('catch error when status search throws error', async () => { + clients.savedObjectsClient.bulkGet.mockReset(); + clients.savedObjectsClient.bulkGet.mockRejectedValue(new Error('Test error')); + const response = await server.inject(getExportTimelinesRequest(), context); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Test error', + status_code: 500, + }); + }); + }); + + describe('request validation', () => { + test('disallows singular id query param', async () => { + const request = requestMock.create({ + method: 'get', + path: TIMELINE_EXPORT_URL, + body: { id: ['someId'] }, + }); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith('"id" is not allowed'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 4bcd3d27a66fc..f3aab06ad8b83 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -5,14 +5,17 @@ */ import { set as _set } from 'lodash/fp'; -import { - SavedObjectsClient, - IRouter, - KibanaRequest, - SavedObjectsBulkResponse, -} from '../../../../../../../../src/core/server'; +import { IRouter, SavedObjectsBulkResponse } from '../../../../../../../../src/core/server'; import { LegacyServices } from '../../../types'; -import { ExportTimelineRequestParams, TimelineSavedObject } from '../types'; +import { + ExportTimelineRequestParams, + ExportTimelineSavedObjectsClient, + ExportTimelineRequest, + ExportedNotes, + TimelineSavedObject, + ExportedTimelines, + BulkGetInput, +} from '../types'; import { timelineSavedObjectType, noteSavedObjectType } from '../../../saved_objects'; import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; @@ -32,44 +35,6 @@ import { } from './schemas/export_timelines_schema'; import { NoteSavedObject } from '../../note/types'; -type ExportTimelineRequest = KibanaRequest< - unknown, - ExportTimelineRequestParams['query'], - ExportTimelineRequestParams['body'], - 'post' ->; - -type ExportTimelineSavedObjectsClient = Pick< - SavedObjectsClient, - | 'get' - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' ->; - -type GlobalNotes = Array>; -type EventNotes = NoteSavedObject[]; - -interface ExportedNotes { - eventNotes: EventNotes; - globalNotes: GlobalNotes; -} - -type ExportedTimelines = TimelineSavedObject & - ExportedNotes & { - pinnedEventIds: string[]; - }; - -interface BulkGetInput { - type: string; - id: string; -} - const getExportTimelineByObjectIds = async ({ client, request, @@ -136,17 +101,17 @@ const getTimelinesFromObjects = async ( bulkGetNotes.length > 0 ? client.bulkGet(bulkGetNotes) : Promise.resolve(null), ]); - const timelineObjects: - | TimelineSavedObject[] - | undefined = savedObjects[0]?.saved_objects.map((savedObject: unknown) => - convertSavedObjectToSavedTimeline(savedObject) - ); + const timelineObjects: TimelineSavedObject[] | undefined = savedObjects[0] + ? savedObjects[0]?.saved_objects.map((savedObject: unknown) => { + return convertSavedObjectToSavedTimeline(savedObject); + }) + : undefined; - const noteObjects: - | NoteSavedObject[] - | undefined = savedObjects[1]?.saved_objects?.map((savedObject: unknown) => - convertSavedObjectToSavedNote(savedObject) - ); + const noteObjects: NoteSavedObject[] | undefined = savedObjects[1] + ? savedObjects[1]?.saved_objects?.map((savedObject: unknown) => + convertSavedObjectToSavedNote(savedObject) + ) + : undefined; return ( timelineObjects?.map(timeline => { @@ -179,16 +144,12 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co const savedObjectsClient = context.core.savedObjects.client; const siemResponse = buildSiemResponse(response); - if (!savedObjectsClient) { - return siemResponse.error({ statusCode: 404 }); - } - try { const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { return siemResponse.error({ statusCode: 400, - body: `Can't export more than ${exportSizeLimit} rules`, + body: `Can't export more than ${exportSizeLimit} timelines`, }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index 3224cf14ee870..ce0cf0335ad2d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -9,8 +9,9 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; -import { NoteSavedObjectToReturnRuntimeType } from '../note/types'; +import { NoteSavedObjectToReturnRuntimeType, NoteSavedObject } from '../note/types'; import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; +import { SavedObjectsClient, KibanaRequest } from '../../../../../../../src/core/server'; /* * ColumnHeader Types @@ -207,3 +208,41 @@ export interface ExportTimelineRequestParams { exclude_export_details: boolean; }; } + +export type ExportTimelineRequest = KibanaRequest< + unknown, + ExportTimelineRequestParams['query'], + ExportTimelineRequestParams['body'], + 'post' +>; + +export type ExportTimelineSavedObjectsClient = Pick< + SavedObjectsClient, + | 'get' + | 'errors' + | 'create' + | 'bulkCreate' + | 'delete' + | 'find' + | 'bulkGet' + | 'update' + | 'bulkUpdate' +>; + +export type ExportedGlobalNotes = Array>; +export type ExportedEventNotes = NoteSavedObject[]; + +export interface ExportedNotes { + eventNotes: ExportedEventNotes; + globalNotes: ExportedGlobalNotes; +} + +export type ExportedTimelines = TimelineSavedObject & + ExportedNotes & { + pinnedEventIds: string[]; + }; + +export interface BulkGetInput { + type: string; + id: string; +} From d3460fada1bb529ed52b672e1fc02b8378fe4c6c Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 11 Mar 2020 18:14:30 +0000 Subject: [PATCH 12/37] fix title for delete timelines --- .../delete_timeline_modal.tsx | 11 +++- .../delete_timeline_modal/index.tsx | 21 ++++-- .../open_timeline/open_timeline.tsx | 65 +++++++++++-------- .../timelines_table/actions_columns.tsx | 2 +- .../components/open_timeline/translations.ts | 4 ++ .../public/components/open_timeline/types.ts | 1 + .../public/pages/timelines/timelines_page.tsx | 27 +------- 7 files changed, 69 insertions(+), 62 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 82fe0d1d162a4..4d00fbd912e87 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -11,7 +11,7 @@ import React from 'react'; import * as i18n from '../translations'; interface Props { - title?: string | null; + title?: string | JSX.Element | null; onDelete: () => void; closeModal: () => void; } @@ -27,9 +27,14 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel 0 ? title.trim() : i18n.UNTITLED_TIMELINE, + title: + typeof title === 'string' + ? title != null && title.trim().length > 0 + ? title.trim() + : i18n.UNTITLED_TIMELINE + : title, }} /> } diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 1d011a702eed5..9cd145e288404 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -71,24 +71,29 @@ export const ActionListIcon = styled(EuiIcon)` interface Props { deleteTimelines?: DeleteTimelines; - savedObjectId?: string | null; - title?: string | null; + savedObjectIds?: string[]; + title?: string | JSX.Element | null; + onComplete?: () => void; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ export const DeleteTimelineModalButton = React.memo( - ({ deleteTimelines, savedObjectId, title }) => { + ({ deleteTimelines, savedObjectIds, title, onComplete }) => { const [showModal, setShowModal] = useState(false); const openModal = () => setShowModal(true); - const closeModal = () => setShowModal(false); + const closeModal = () => { + setShowModal(false); + if (typeof onComplete === 'function') onComplete(); + }; const onDelete = () => { - if (deleteTimelines != null && savedObjectId != null) { - deleteTimelines([savedObjectId]); + if (deleteTimelines != null && savedObjectIds != null) { + deleteTimelines(savedObjectIds); } closeModal(); + if (typeof onComplete === 'function') onComplete(); }; return ( @@ -97,7 +102,9 @@ export const DeleteTimelineModalButton = React.memo( <> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 3913c35290505..b3b04ac4619c8 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -23,6 +23,7 @@ import { import * as i18n from './translations'; import { useStateToaster } from '../toasters'; import { TimelineDownloader } from './export_timeline/export_timeline'; +import { DeleteTimelineModalButton } from './delete_timeline_modal'; export interface ExportTimelineIds { timelineId: string | null | undefined; pinnedEventIds: string[] | null | undefined; @@ -57,12 +58,12 @@ export const OpenTimeline = React.memo( }) => { const [, dispatchToaster] = useStateToaster(); - const text = useMemo( + const nTimelines = useMemo( () => ( ( ); const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => ( - - - , - { - closePopover(); - if (typeof onDeleteSelected === 'function') onDeleteSelected(); - }} - > - {i18n.DELETE_SELECTED} - , - ]} - /> - ), + (closePopover: () => void) => { + return ( + + + , + + + item.savedObjectId != null ? [...acc, item.savedObjectId] : acc, + [] as string[] + )} + title={ + selectedItems.length > 1 + ? i18n.SELECTED_TIMELINES(selectedItems.length) + : `"${selectedItems[0]?.title}"` + } + onComplete={closePopover} + /> + , + ]} + /> + ); + }, [selectedItems, dispatchToaster, history] ); @@ -125,7 +133,12 @@ export const OpenTimeline = React.memo( - {text} + + <> + {i18n.SHOWING} + {nTimelines} + + diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index c1dde2dec33f8..bb369695ca961 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -56,7 +56,7 @@ export const getActionsColumns = ({ render: ({ savedObjectId, title }: OpenTimelineResult) => ( ), diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts index d2b48d9cbff2e..4063b73d9499a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/translations.ts @@ -135,6 +135,10 @@ export const SELECTED_TIMELINES = (selectedTimelines: number) => 'Selected {selectedTimelines} {selectedTimelines, plural, =1 {timeline} other {timelines}}', }); +export const SHOWING = i18n.translate('xpack.siem.open.timeline.showingLabel', { + defaultMessage: 'Showing:', +}); + export const SUCCESSFULLY_EXPORTED_TIMELINES = (totalTimelines: number) => i18n.translate('xpack.siem.open.timeline.successfullyExportedTimelinesTitle', { values: { totalTimelines }, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 57f37734af7ad..1aa2afdd9a882 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -142,6 +142,7 @@ export interface OpenTimelineProps { query: string; /** Refetch timelines data */ refetch?: Refetch; + /** The results of executing a search */ searchResults: OpenTimelineResult[]; /** the currently-selected timelines in the table */ diff --git a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx index c5a26f1751691..6d30ea58089f0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/timelines/timelines_page.tsx @@ -5,7 +5,7 @@ */ import ApolloClient from 'apollo-client'; -import React, { useState, useCallback } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { HeaderPage } from '../../components/header_page'; @@ -13,7 +13,6 @@ import { StatefulOpenTimeline } from '../../components/open_timeline'; import { WrapperPage } from '../../components/wrapper_page'; import { SpyRoute } from '../../utils/route/spy_routes'; import * as i18n from './translations'; -import { ImportRuleModal } from '../detection_engine/rules/components/import_rule_modal'; const TimelinesContainer = styled.div` width: 100%; @@ -28,32 +27,10 @@ type OwnProps = TimelinesProps; export const DEFAULT_SEARCH_RESULTS_PER_PAGE = 10; const TimelinesPageComponent: React.FC = ({ apolloClient }) => { - const [showImportModal, setShowImportModal] = useState(false); - const toggleImportModal = useCallback( - newState => { - setShowImportModal(newState); - }, - [setShowImportModal] - ); return ( <> - { - /* setImportCompleteToggle(!importCompleteToggle)*/ - }} - /> - - {/* { - {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} - } */} - + Date: Wed, 11 Mar 2020 21:02:19 +0000 Subject: [PATCH 13/37] fix unit tests --- .../delete_timeline_modal.test.tsx | 8 +- .../delete_timeline_modal.tsx | 15 +- .../delete_timeline_modal/index.test.tsx | 37 ++- .../delete_timeline_modal/index.tsx | 9 +- .../components/open_timeline/index.test.tsx | 5 - .../open_timeline/open_timeline.test.tsx | 266 ++++++++++++++++++ .../open_timeline/open_timeline.tsx | 8 +- .../open_timeline/search_row/index.test.tsx | 151 ---------- .../timelines_table/actions_columns.test.tsx | 3 +- .../timelines_table/actions_columns.tsx | 2 + .../open_timeline/timelines_table/index.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- 12 files changed, 312 insertions(+), 196 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index e061141bf43e7..8a9aa4b53d41f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -12,10 +12,10 @@ import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; describe('DeleteTimelineModal', () => { - test('it renders the expected title when a title is specified', () => { + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( @@ -29,10 +29,10 @@ describe('DeleteTimelineModal', () => { ).toEqual('Delete "Privilege Escalation"?'); }); - test('it trims leading and trailing whitespace around the title', () => { + test('it trims leading whitespace around the title', () => { const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 4d00fbd912e87..9f142dbb0f5d6 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -18,6 +18,14 @@ interface Props { export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px +const getDeletedTitles = (title: string | JSX.Element | null | undefined) => { + if (title == null) return `"${i18n.UNTITLED_TIMELINE}"`; + + if (typeof title === 'string') { + return title.trim().length > 0 ? title.trim() : `"${i18n.UNTITLED_TIMELINE}"`; + } else return title; +}; + /** * Renders a modal that confirms deletion of a timeline */ @@ -29,12 +37,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel data-test-subj="title" defaultMessage="Delete {title}?" values={{ - title: - typeof title === 'string' - ? title != null && title.trim().length > 0 - ? title.trim() - : i18n.UNTITLED_TIMELINE - : title, + title: getDeletedTitles(title), }} /> } diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index a3c5371435e52..7eedba87b69fc 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIconProps } from '@elastic/eui'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; @@ -16,73 +15,73 @@ describe('DeleteTimelineModal', () => { describe('showModalState', () => { test('it disables the delete icon if deleteTimelines is not provided', () => { const wrapper = mountWithIntl( - + ); const props = wrapper - .find('[data-test-subj="delete-timeline"]') + .find('[data-test-subj="delete-timeline-wrapper"]') .first() - .props() as EuiButtonIconProps; + .props(); - expect(props.isDisabled).toBe(true); + expect(props.disabled).toBe(true); }); test('it disables the delete icon if savedObjectId is null', () => { const wrapper = mountWithIntl( ); const props = wrapper - .find('[data-test-subj="delete-timeline"]') + .find('[data-test-subj="delete-timeline-wrapper"]') .first() - .props() as EuiButtonIconProps; + .props(); - expect(props.isDisabled).toBe(true); + expect(props.disabled).toBe(true); }); test('it disables the delete icon if savedObjectId is an empty string', () => { const wrapper = mountWithIntl( ); const props = wrapper - .find('[data-test-subj="delete-timeline"]') + .find('[data-test-subj="delete-timeline-wrapper"]') .first() - .props() as EuiButtonIconProps; + .props(); - expect(props.isDisabled).toBe(true); + expect(props.disabled).toBe(true); }); test('it enables the delete icon if savedObjectId is NOT an empty string', () => { const wrapper = mountWithIntl( ); const props = wrapper - .find('[data-test-subj="delete-timeline"]') + .find('[data-test-subj="delete-timeline-wrapper"]') .first() - .props() as EuiButtonIconProps; + .props(); - expect(props.isDisabled).toBe(false); + expect(props.disabled).toBe(false); }); test('it does NOT render the modal when showModal is false', () => { const wrapper = mountWithIntl( ); @@ -99,7 +98,7 @@ describe('DeleteTimelineModal', () => { const wrapper = mountWithIntl( ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 9cd145e288404..ade061b61d6c2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -71,7 +71,7 @@ export const ActionListIcon = styled(EuiIcon)` interface Props { deleteTimelines?: DeleteTimelines; - savedObjectIds?: string[]; + savedObjectIds?: string[] | null | undefined; title?: string | JSX.Element | null; onComplete?: () => void; } @@ -102,13 +102,16 @@ export const DeleteTimelineModalButton = React.memo( id != null && id.length > 0).length === 0 } onClick={openModal} > <> - + {i18n.DELETE_SELECTED} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx index 520e2094fb336..04f0abe0d00d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/index.test.tsx @@ -526,11 +526,6 @@ describe('StatefulOpenTimeline', () => { .first() .simulate('change', { target: { checked: true } }); expect(getSelectedItem().length).toEqual(13); - wrapper - .find('[data-test-subj="delete-selected"]') - .first() - .simulate('click'); - expect(getSelectedItem().length).toEqual(0); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx index a1ca7812bba34..e010d54d711c3 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.test.tsx @@ -290,4 +290,270 @@ describe('OpenTimeline', () => { expect(props.actionTimelineToShow).not.toContain('delete'); }); + + test('it renders an empty string when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it renders the expected message when the query just has spaces', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toEqual(''); + }); + + test('it echos the query when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Would you like to go to Denver?'); + }); + + test('trims whitespace from the ends of the query', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selectable-query-text"]') + .first() + .text() + ).toContain('Is it starting to feel cramped in here?'); + }); + + test('it renders the expected message when the query is an empty string', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it renders the expected message when the query just has whitespace', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines `); + }); + + test('it includes the word "with" when the query has non-whitespace characters', () => { + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find('[data-test-subj="query-message"]') + .first() + .text() + ).toContain(`Showing: ${mockResults.length} timelines with "How was your day?"`); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index b3b04ac4619c8..c8d790470f6c1 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -61,7 +61,6 @@ export const OpenTimeline = React.memo( const nTimelines = useMemo( () => ( ( }} /> ), - [totalSearchResultsCount] + [totalSearchResultsCount, query] ); const getBatchItemsPopoverContent = useCallback( @@ -133,10 +132,9 @@ export const OpenTimeline = React.memo( - + <> - {i18n.SHOWING} - {nTimelines} + {i18n.SHOWING} {nTimelines} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.test.tsx index 1a4708ed5af08..6d8d9be957107 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.test.tsx @@ -149,155 +149,4 @@ describe('SearchRow', () => { expect(onQueryChange).toHaveBeenCalled(); }); }); - - describe('Showing message', () => { - test('it renders the expected message when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it renders the expected message when the query just has whitespace', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines '); - }); - - test('it includes the word "with" when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="query-message"]') - .first() - .text() - ).toContain('Showing: 32 timelines with'); - }); - }); - - describe('selectable query text', () => { - test('it renders an empty string when the query is an empty string', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it renders the expected message when the query just has spaces', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toEqual(''); - }); - - test('it echos the query when the query has non-whitespace characters', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Would you like to go to Denver?'); - }); - - test('trims whitespace from the ends of the query', () => { - const wrapper = mountWithIntl( - - - - ); - - expect( - wrapper - .find('[data-test-subj="selectable-query-text"]') - .first() - .text() - ).toContain('Is it starting to feel cramped in here?'); - }); - }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index eec11f571328f..45b8c8f6e0ed2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -14,11 +14,12 @@ import { ThemeProvider } from 'styled-components'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; jest.mock('../../../lib/kibana'); +const { TimelinesTable } = jest.requireActual('.'); + describe('#getActionsColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index bb369695ca961..83e09de724d72 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -39,7 +39,9 @@ export const getActionsColumns = ({ timelineId: savedObjectId ?? '', }); }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.OPEN_AS_DUPLICATE, + 'data-test-subj': 'open-duplicate', }; const exportTimelineAction = { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index 039643a34f400..d1b95367d99a7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -47,7 +47,7 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => * `Timelines` page */ -const getTimelinesTableColumns = ({ +export const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index 74176aeab0360..bd556e695e584 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -58,7 +58,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` `; From 697ce9406b06ee579cbd4f57344907a4eec5b11d Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 11 Mar 2020 23:01:24 +0000 Subject: [PATCH 14/37] update snapshot --- .../__snapshots__/source_index_preview.test.tsx.snap | 1 + .../step_create/__snapshots__/step_create_form.test.tsx.snap | 1 + .../step_define/__snapshots__/pivot_preview.test.tsx.snap | 1 + .../step_define/__snapshots__/step_define_form.test.tsx.snap | 1 + 4 files changed, 4 insertions(+) diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap index 6d2d3d5c4a6a5..ebce62474efc1 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/source_index_preview/__snapshots__/source_index_preview.test.tsx.snap @@ -547,6 +547,7 @@ exports[`Transform: Minimal initialization 1`] = ` "detachAction": [MockFunction], "executeTriggerActions": [MockFunction], "fork": [MockFunction], + "getAction": [MockFunction], "getTrigger": [MockFunction], "getTriggerActions": [MockFunction], "getTriggerCompatibleActions": [MockFunction], diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap index db4ff0c1a99ae..94aaac463a98a 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_create/__snapshots__/step_create_form.test.tsx.snap @@ -547,6 +547,7 @@ exports[`Transform: Minimal initialization 1`] = ` "detachAction": [MockFunction], "executeTriggerActions": [MockFunction], "fork": [MockFunction], + "getAction": [MockFunction], "getTrigger": [MockFunction], "getTriggerActions": [MockFunction], "getTriggerCompatibleActions": [MockFunction], diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap index bc0d983c6e022..9081cfd664e0f 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/pivot_preview.test.tsx.snap @@ -547,6 +547,7 @@ exports[`Transform: Minimal initialization 1`] = ` "detachAction": [MockFunction], "executeTriggerActions": [MockFunction], "fork": [MockFunction], + "getAction": [MockFunction], "getTrigger": [MockFunction], "getTriggerActions": [MockFunction], "getTriggerCompatibleActions": [MockFunction], diff --git a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap index 30c57a9f3f4ae..0e92793e897a3 100644 --- a/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap +++ b/x-pack/legacy/plugins/transform/public/app/sections/create_transform/components/step_define/__snapshots__/step_define_form.test.tsx.snap @@ -547,6 +547,7 @@ exports[`Transform: Minimal initialization 1`] = ` "detachAction": [MockFunction], "executeTriggerActions": [MockFunction], "fork": [MockFunction], + "getAction": [MockFunction], "getTrigger": [MockFunction], "getTriggerActions": [MockFunction], "getTriggerCompatibleActions": [MockFunction], From 1cde9705dac003dd53a4646c34d9169de01f7731 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 12 Mar 2020 10:39:11 +0000 Subject: [PATCH 15/37] fix dependency --- .../open_timeline/export_timeline/index.tsx | 2 -- .../components/open_timeline/open_timeline.tsx | 14 +++++++------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index bbbb0aa52a222..ecbcd57ba0d71 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -8,7 +8,6 @@ import { useState, useCallback, Dispatch, SetStateAction } from 'react'; import { keys } from 'lodash/fp'; import { OpenTimelineResult } from '../types'; import { KibanaServices } from '../../../lib/kibana'; -import { throwIfNotOk } from '../../../hooks/api/api'; import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; @@ -49,7 +48,6 @@ export const useExportTimeline = ( asResponse: true, }); - await throwIfNotOk(response.response); return response.body!; }, [selectedItems] diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index c8d790470f6c1..4fdb1b315ab16 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -12,18 +12,18 @@ import { OpenTimelineProps } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; -import { - UtilityBar, - UtilityBarSection, - UtilityBarGroup, - UtilityBarText, - UtilityBarAction, -} from '../detection_engine/utility_bar'; import * as i18n from './translations'; import { useStateToaster } from '../toasters'; import { TimelineDownloader } from './export_timeline/export_timeline'; import { DeleteTimelineModalButton } from './delete_timeline_modal'; +import { + UtilityBarGroup, + UtilityBarText, + UtilityBar, + UtilityBarSection, + UtilityBarAction, +} from '../utility_bar'; export interface ExportTimelineIds { timelineId: string | null | undefined; pinnedEventIds: string[] | null | undefined; From b7fd481009ae720f372d68222d80b328cb79700a Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 12 Mar 2020 13:55:26 +0000 Subject: [PATCH 16/37] add table ref --- .../open_timeline/open_timeline.tsx | 19 +++++++++++++++---- .../open_timeline/timelines_table/index.tsx | 6 +++++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 4fdb1b315ab16..2340b59326b80 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; -import React, { useMemo, useCallback } from 'react'; +import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; +import React, { useMemo, useCallback, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps } from './types'; @@ -56,6 +56,7 @@ export const OpenTimeline = React.memo( title, totalSearchResultsCount, }) => { + const tableRef = useRef(); const [, dispatchToaster] = useStateToaster(); const nTimelines = useMemo( @@ -75,6 +76,15 @@ export const OpenTimeline = React.memo( ), [totalSearchResultsCount, query] ); + const onCompleteActions = useCallback( + (closePopover: () => void) => { + closePopover(); + if (tableRef != null && tableRef.current != null) { + tableRef.current.changeSelection([]); + } + }, + [tableRef.current] + ); const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { @@ -84,7 +94,7 @@ export const OpenTimeline = React.memo( , @@ -100,7 +110,7 @@ export const OpenTimeline = React.memo( ? i18n.SELECTED_TIMELINES(selectedItems.length) : `"${selectedItems[0]?.title}"` } - onComplete={closePopover} + onComplete={onCompleteActions.bind(null, closePopover)} /> , ]} @@ -182,6 +192,7 @@ export const OpenTimeline = React.memo( showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} + tableRef={tableRef} totalSearchResultsCount={totalSearchResultsCount} /> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index d1b95367d99a7..17b292c3bd493 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -94,6 +94,8 @@ export interface TimelinesTableProps { showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; totalSearchResultsCount: number; } @@ -118,6 +120,7 @@ export const TimelinesTable = React.memo( showExtendedColumns, sortField, sortDirection, + tableRef, totalSearchResultsCount, }) => { const pagination = { @@ -145,7 +148,7 @@ export const TimelinesTable = React.memo( !selectable ? i18n.MISSING_SAVED_OBJECT_ID : undefined, onSelectionChange, }; - + const basicTableProps = tableRef != null ? { ref: tableRef } : {}; return ( ( pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} sorting={sorting} + {...basicTableProps} /> ); } From 562876793f1fd3d38d72d3fb619cf7ec1ec775c4 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Sat, 14 Mar 2020 00:42:10 +0000 Subject: [PATCH 17/37] remove custom link --- .../delete_timeline_modal/index.tsx | 107 +++--------- .../export_timeline/export_timeline.tsx | 42 ++--- .../open_timeline/export_timeline/index.tsx | 18 +- .../open_timeline/open_timeline.tsx | 154 ++++++++++++++---- .../timelines_table/actions_columns.tsx | 32 ++-- .../open_timeline/timelines_table/index.tsx | 19 ++- .../public/components/open_timeline/types.ts | 4 + 7 files changed, 207 insertions(+), 169 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index ade061b61d6c2..a81a8d1b6573d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -3,126 +3,60 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { EuiModal, EuiOverlayMask, EuiIcon, EuiLinkAnchorProps } from '@elastic/eui'; -import React, { useState } from 'react'; +import { EuiModal, EuiOverlayMask, EuiIcon, EuiLink } from '@elastic/eui'; +import React, { useCallback } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; - -import classNames from 'classnames'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; import * as i18n from '../translations'; import { DeleteTimelines } from '../types'; - const RemovePopover = createGlobalStyle` div.euiPopover__panel-isOpen { display: none; } `; - -type CustomLinkType = EuiLinkAnchorProps & { disabled: boolean }; - -export const CustomLink = React.forwardRef( - ( - { - children, - color = 'primary', - className, - href, - external, - target, - rel, - type = 'button', - onClick, - disabled, - ...rest - }, - ref - ) => { - const anchorProps = { - className: classNames('euiLink', disabled ? 'euiLink-disabled' : 'euiLink--text', className), - href, - target, - onClick, - ...rest, - }; - - return !disabled ? ( - } {...anchorProps}> - {children} - - ) : ( - {children} - ); - } -); - -CustomLink.displayName = 'CustomLink'; - -export const TimelineCustomAction = styled(CustomLink)` +export const TimelineCustomAction = styled(EuiLink)` width: 100%; - .euiButtonEmpty__content a { - justify-content: flex-start; - } `; - export const ActionListIcon = styled(EuiIcon)` margin-right: 8px; `; - interface Props { - deleteTimelines?: DeleteTimelines; + deleteTimelines: DeleteTimelines; + isModalOpen: boolean; + closeModal: () => void; savedObjectIds?: string[] | null | undefined; - title?: string | JSX.Element | null; + title: string | JSX.Element | null; onComplete?: () => void; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ export const DeleteTimelineModalButton = React.memo( - ({ deleteTimelines, savedObjectIds, title, onComplete }) => { - const [showModal, setShowModal] = useState(false); - - const openModal = () => setShowModal(true); - const closeModal = () => { - setShowModal(false); - if (typeof onComplete === 'function') onComplete(); - }; - - const onDelete = () => { + ({ closeModal, deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { + const internalCloseModal = useCallback(() => { + closeModal(); + if (onComplete != null) { + onComplete(); + } + }, [closeModal, onComplete]); + const onDelete = useCallback(() => { if (deleteTimelines != null && savedObjectIds != null) { deleteTimelines(savedObjectIds); } closeModal(); - if (typeof onComplete === 'function') onComplete(); - }; - + if (onComplete != null) onComplete(); + }, [closeModal, deleteTimelines, savedObjectIds]); return ( <> - {showModal && } - id != null && id.length > 0).length === 0 - } - onClick={openModal} - > - <> - - {i18n.DELETE_SELECTED} - - - {showModal ? ( + {isModalOpen && } + {isModalOpen ? ( @@ -131,5 +65,4 @@ export const DeleteTimelineModalButton = React.memo( ); } ); - DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx index 50ef94e43a225..f8e2c32eddbf7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -6,31 +6,37 @@ import React from 'react'; import uuid from 'uuid'; -import { OpenTimelineResult } from '../types'; -import { GenericDownloader } from '../../generic_downloader'; +import { OpenTimelineResult, DisableExportTimelineDownloader } from '../types'; +import { GenericDownloader, ExportSelectedData } from '../../generic_downloader'; import * as i18n from '../translations'; -import { ActionListIcon, TimelineCustomAction } from '../delete_timeline_modal'; -import { useExportTimeline } from '.'; import { useStateToaster } from '../../toasters'; const ExportTimeline: React.FC<{ - selectedTimelines: OpenTimelineResult[] | undefined; + isEnableDownloader: boolean; onDownloadComplete?: () => void; -}> = ({ selectedTimelines, onDownloadComplete }) => { - const { enableDownloader, setEnableDownloader, exportedIds, getExportedData } = useExportTimeline( - selectedTimelines - ); + selectedItems: OpenTimelineResult[] | undefined; + exportedIds: string; + getExportedData: ExportSelectedData; + disableExportTimelineDownloader: DisableExportTimelineDownloader; +}> = ({ + onDownloadComplete, + isEnableDownloader, + disableExportTimelineDownloader, + selectedItems, + exportedIds, + getExportedData, +}) => { const [, dispatchToaster] = useStateToaster(); return ( <> - {selectedTimelines != null && exportedIds != null && enableDownloader && ( + {selectedItems != null && exportedIds != null && isEnableDownloader && ( { - setEnableDownloader(false); + disableExportTimelineDownloader(); if (typeof onDownloadComplete === 'function') onDownloadComplete(); dispatchToaster({ type: 'addToaster', @@ -44,20 +50,6 @@ const ExportTimeline: React.FC<{ }} /> )} - { - setEnableDownloader(true); - }} - data-test-subj="export-timeline" - > - <> - - {i18n.EXPORT_SELECTED} - - ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index ecbcd57ba0d71..234fa57c9ef9b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -12,8 +12,8 @@ import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; export interface ExportTimeline { - enableDownloader: boolean; - setEnableDownloader: Dispatch>; + isEnableDownloader: boolean; + setIsEnableDownloader: Dispatch>; exportedIds: | Array<{ timelineId: string | null | undefined; @@ -25,10 +25,9 @@ export interface ExportTimeline { } export const useExportTimeline = ( - selectedItems: OpenTimelineResult[] | undefined + selectedItems?: OpenTimelineResult | OpenTimelineResult[] ): ExportTimeline => { - const [enableDownloader, setEnableDownloader] = useState(false); - + const [isEnableDownloader, setIsEnableDownloader] = useState(false); const exportSelectedTimeline: ExportSelectedData = useCallback( async ({ excludeExportDetails = false, @@ -54,8 +53,9 @@ export const useExportTimeline = ( ); const getExportedIds = useCallback( - (selectedTimelines: OpenTimelineResult[]) => { - return selectedTimelines.map(item => ({ + (selectedTimelines: OpenTimelineResult | OpenTimelineResult[]) => { + const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; + return array.map(item => ({ timelineId: item.savedObjectId, pinnedEventIds: item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, @@ -69,8 +69,8 @@ export const useExportTimeline = ( ); return { - enableDownloader, - setEnableDownloader, + isEnableDownloader, + setIsEnableDownloader, exportedIds: selectedItems != null ? getExportedIds(selectedItems) : undefined, getExportedData: exportSelectedTimeline, }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 2340b59326b80..f4e87491625b2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -5,10 +5,10 @@ */ import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; -import React, { useMemo, useCallback, useRef } from 'react'; +import React, { useMemo, useCallback, useRef, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { OpenTimelineProps } from './types'; +import { OpenTimelineProps, OpenTimelineResult } from './types'; import { SearchRow } from './search_row'; import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; @@ -24,6 +24,7 @@ import { UtilityBarSection, UtilityBarAction, } from '../utility_bar'; +import { useExportTimeline } from './export_timeline'; export interface ExportTimelineIds { timelineId: string | null | undefined; pinnedEventIds: string[] | null | undefined; @@ -58,6 +59,38 @@ export const OpenTimeline = React.memo( }) => { const tableRef = useRef(); const [, dispatchToaster] = useStateToaster(); + const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); + const [actionItem, setActionTimeline] = useState(undefined); + const { + isEnableDownloader, + setIsEnableDownloader, + exportedIds, + getExportedData, + } = useExportTimeline(actionItem ? [actionItem] : selectedItems); + const enableExportTimelineDownloader = useCallback( + (selectedActionItem?: OpenTimelineResult) => { + setIsEnableDownloader(true); + setActionTimeline(selectedActionItem); + }, + [setIsEnableDownloader, setActionTimeline] + ); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + }, [setIsEnableDownloader]); + + const onCloseDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setActionTimeline(undefined); + }, [setIsDeleteTimelineModalOpen]); + + const onOpenDeleteTimelineModal = useCallback( + (selectedActionItem?: OpenTimelineResult) => { + setIsDeleteTimelineModalOpen(true); + setActionTimeline(selectedActionItem); + }, + [setIsDeleteTimelineModalOpen, setActionTimeline] + ); const nTimelines = useMemo( () => ( @@ -76,9 +109,9 @@ export const OpenTimeline = React.memo( ), [totalSearchResultsCount, query] ); - const onCompleteActions = useCallback( - (closePopover: () => void) => { - closePopover(); + const onCompleteBatchActions = useCallback( + (closePopover?: () => void) => { + if (closePopover != null) closePopover(); if (tableRef != null && tableRef.current != null) { tableRef.current.changeSelection([]); } @@ -89,39 +122,94 @@ export const OpenTimeline = React.memo( const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { return ( - - - , - - - item.savedObjectId != null ? [...acc, item.savedObjectId] : acc, - [] as string[] - )} - title={ - selectedItems.length > 1 - ? i18n.SELECTED_TIMELINES(selectedItems.length) - : `"${selectedItems[0]?.title}"` - } - onComplete={onCompleteActions.bind(null, closePopover)} - /> - , - ]} - /> + <> + + {deleteTimelines != null && ( + (item.savedObjectId != null ? [...acc, item.savedObjectId] : acc), + [] as string[] + )} + title={ + selectedItems.length > 1 + ? i18n.SELECTED_TIMELINES(selectedItems.length) + : `"${selectedItems[0]?.title}"` + } + /> + )} + { + enableExportTimelineDownloader(); + }} + > + {i18n.EXPORT_SELECTED} + , + { + onOpenDeleteTimelineModal(); + }} + > + {i18n.DELETE_SELECTED} + , + ]} + /> + ); }, - [selectedItems, dispatchToaster, history] + [ + actionItem, + deleteTimelines, + dispatchToaster, + history, + isEnableDownloader, + isDeleteTimelineModalOpen, + selectedItems, + onCloseDeleteTimelineModal, + onCompleteBatchActions, + exportedIds, + getExportedData, + ] ); return ( <> + + {deleteTimelines != null && ( + + )} ( defaultPageSize={defaultPageSize} loading={isLoading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} + enableExportTimelineDownloader={enableExportTimelineDownloader} + onOpenDeleteTimelineModal={onOpenDeleteTimelineModal} onOpenTimeline={onOpenTimeline} onSelectionChange={onSelectionChange} onTableChange={onTableChange} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx index 83e09de724d72..da9013f646a26 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.tsx @@ -6,28 +6,31 @@ /* eslint-disable react/display-name */ -import React from 'react'; - import { ActionTimelineToShow, DeleteTimelines, + EnableExportTimelineDownloader, OnOpenTimeline, OpenTimelineResult, + OnOpenDeleteTimelineModal, TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; -import { DeleteTimelineModalButton } from '../delete_timeline_modal'; -import { TimelineDownloader } from '../export_timeline/export_timeline'; + /** * Returns the action columns (e.g. delete, open duplicate timeline) */ export const getActionsColumns = ({ actionTimelineToShow, - onOpenTimeline, deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + onOpenTimeline, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; }): [TimelineActionsOverflowColumns] => { const openAsDuplicateColumn = { @@ -47,21 +50,20 @@ export const getActionsColumns = ({ const exportTimelineAction = { name: i18n.EXPORT_SELECTED, icon: 'exportAction', - render: (selectedTimeline: OpenTimelineResult) => ( - - ), + onClick: (selectedTimeline: OpenTimelineResult) => { + enableExportTimelineDownloader(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.EXPORT_SELECTED, }; const deleteTimelineColumn = { name: i18n.DELETE_SELECTED, - render: ({ savedObjectId, title }: OpenTimelineResult) => ( - - ), + icon: 'trash', + onClick: (selectedTimeline: OpenTimelineResult) => { + onOpenDeleteTimelineModal(selectedTimeline); + }, + enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.DELETE_SELECTED, }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index 17b292c3bd493..e3466c30f685e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -17,6 +17,8 @@ import { OnTableChange, OnToggleShowNotes, OpenTimelineResult, + EnableExportTimelineDownloader, + OnOpenDeleteTimelineModal, } from '../types'; import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; @@ -50,15 +52,21 @@ const getExtendedColumnsIfEnabled = (showExtendedColumns: boolean) => export const getTimelinesTableColumns = ({ actionTimelineToShow, deleteTimelines, + enableExportTimelineDownloader, itemIdToExpandedNotesRowMap, + onOpenDeleteTimelineModal, onOpenTimeline, + onSelectionChange, onToggleShowNotes, showExtendedColumns, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; + enableExportTimelineDownloader: EnableExportTimelineDownloader; itemIdToExpandedNotesRowMap: Record; + onOpenDeleteTimelineModal: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; + onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; }) => { @@ -71,9 +79,11 @@ export const getTimelinesTableColumns = ({ ...getExtendedColumnsIfEnabled(showExtendedColumns), ...getIconHeaderColumns(), ...getActionsColumns({ + actionTimelineToShow, deleteTimelines, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, onOpenTimeline, - actionTimelineToShow, }), ]; }; @@ -84,6 +94,8 @@ export interface TimelinesTableProps { defaultPageSize: number; loading: boolean; itemIdToExpandedNotesRowMap: Record; + enableExportTimelineDownloader: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; onSelectionChange: OnSelectionChange; onTableChange: OnTableChange; @@ -110,6 +122,8 @@ export const TimelinesTable = React.memo( defaultPageSize, loading: isLoading, itemIdToExpandedNotesRowMap, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, onOpenTimeline, onSelectionChange, onTableChange, @@ -155,7 +169,10 @@ export const TimelinesTable = React.memo( actionTimelineToShow, deleteTimelines, itemIdToExpandedNotesRowMap, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, onOpenTimeline, + onSelectionChange, onToggleShowNotes, showExtendedColumns, })} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 1aa2afdd9a882..4502156fcb383 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -78,6 +78,10 @@ export type OnOpenTimeline = ({ timelineId: string; }) => void; +export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; +export type OnCloseDeleteTimelineModal = () => void; +export type DisableExportTimelineDownloader = () => void; +export type EnableExportTimelineDownloader = (selectedItem: OpenTimelineResult) => void; /** Invoked when the user presses enters to submit the text in the search input */ export type OnQueryChange = (query: EuiSearchBarQuery) => void; From c00f9e7da9f4fbed4fb4c7437f58841b66efdc74 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Sat, 14 Mar 2020 21:52:12 +0000 Subject: [PATCH 18/37] remove custom links --- .../generic_downloader/index.test.tsx | 4 +- .../components/generic_downloader/index.tsx | 26 +- .../delete_timeline_modal.tsx | 42 +- .../delete_timeline_modal/index.test.tsx | 109 +-- .../delete_timeline_modal/index.tsx | 7 +- .../open_timeline/edit_timeline_actions.tsx | 29 + .../edit_timeline_batch_actions.tsx | 120 ++++ .../export_timeline/export_timeline.test.tsx | 72 +- .../export_timeline/export_timeline.tsx | 51 +- .../export_timeline/index.test.tsx | 25 +- .../open_timeline/export_timeline/index.tsx | 108 ++- .../open_timeline/open_timeline.tsx | 161 +---- .../timelines_table/actions_columns.tsx | 9 +- .../timelines_table/common_columns.test.tsx | 631 ++++-------------- .../timelines_table/extended_columns.test.tsx | 90 +-- .../icon_header_columns.test.tsx | 257 ++----- .../timelines_table/index.test.tsx | 376 +++-------- .../open_timeline/timelines_table/index.tsx | 9 +- .../public/components/open_timeline/types.ts | 8 + .../detection_engine/rules/types.ts | 2 +- .../detection_engine/rules/all/index.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../rule_actions_overflow/index.tsx | 2 +- 23 files changed, 764 insertions(+), 1378 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx index 4768c81bbb8f8..5d8c3fa09f5e5 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx @@ -6,12 +6,12 @@ import { shallow } from 'enzyme'; import React from 'react'; -import { RuleDownloaderComponent } from './index'; +import { GenericDownloaderComponent } from './index'; describe('GenericDownloader', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 83ebd29ee5e99..4de6b39eaa6f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -8,9 +8,10 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; import { isFunction, isNil } from 'lodash/fp'; import * as i18n from './translations'; -import { ExportTimelineIds } from '../open_timeline/open_timeline'; + import { exportRules, ExportDocumentsProps } from '../../containers/detection_engine/rules'; import { useStateToaster, errorToToaster } from '../toasters'; +import { ExportTimelineIds } from '../open_timeline/export_timeline'; const InvisibleAnchor = styled.a` display: none; @@ -23,12 +24,13 @@ export type ExportSelectedData = ({ signal, }: ExportDocumentsProps) => Promise; -export interface RuleDownloaderProps { +export interface GenericDownloaderProps { filename: string; ids?: ExportTimelineIds[]; ruleIds?: string[]; exportSelectedData?: ExportSelectedData; - onExportComplete: (exportCount: number) => void; + onExportSuccess: (exportCount: number) => void; + onExportFailure?: () => void; } /** @@ -39,13 +41,14 @@ export interface RuleDownloaderProps { * */ -export const RuleDownloaderComponent = ({ +export const GenericDownloaderComponent = ({ exportSelectedData, filename, ids, ruleIds, - onExportComplete, -}: RuleDownloaderProps) => { + onExportSuccess, + onExportFailure, +}: GenericDownloaderProps) => { const anchorRef = useRef(null); const [, dispatchToaster] = useStateToaster(); @@ -85,13 +88,14 @@ export const RuleDownloaderComponent = ({ window.URL.revokeObjectURL(objectURL); } - if (typeof onExportComplete === 'function') { - if (ruleIds != null) onExportComplete(ruleIds.length); - else if (ids != null) onExportComplete(ids.length); + if (onExportSuccess != null) { + if (ruleIds != null) onExportSuccess(ruleIds.length); + else if (ids != null) onExportSuccess(ids.length); } } } catch (error) { if (isSubscribed) { + if (onExportFailure != null) onExportFailure(); errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster }); } } @@ -109,8 +113,8 @@ export const RuleDownloaderComponent = ({ return ; }; -RuleDownloaderComponent.displayName = 'RuleDownloaderComponent'; +GenericDownloaderComponent.displayName = 'GenericDownloaderComponent'; -export const GenericDownloader = React.memo(RuleDownloaderComponent); +export const GenericDownloader = React.memo(GenericDownloaderComponent); GenericDownloader.displayName = 'GenericDownloader'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 9f142dbb0f5d6..640fba5942a23 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -6,9 +6,10 @@ import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useCallback, useState } from 'react'; import * as i18n from '../translations'; +import { OpenTimelineResult, SetActionTimeline } from '../types'; interface Props { title?: string | JSX.Element | null; @@ -31,6 +32,12 @@ const getDeletedTitles = (title: string | JSX.Element | null | undefined) => { */ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => ( (({ title, closeModal, onDel }} /> } - onCancel={closeModal} - onConfirm={onDelete} - cancelButtonText={i18n.CANCEL} - confirmButtonText={i18n.DELETE} - buttonColor="danger" - defaultFocusedButton={EUI_MODAL_CONFIRM_BUTTON} >
{i18n.DELETE_WARNING}
)); DeleteTimelineModal.displayName = 'DeleteTimelineModal'; + +export const useDeleteTimeline = ({ + setActionTimeline, +}: { + setActionTimeline: SetActionTimeline; +}) => { + const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); + + const onCloseDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setActionTimeline(undefined); + }, [setIsDeleteTimelineModalOpen]); + + const onOpenDeleteTimelineModal = useCallback( + (selectedActionItem?: OpenTimelineResult) => { + setIsDeleteTimelineModalOpen(true); + setActionTimeline(selectedActionItem); + }, + [setIsDeleteTimelineModalOpen, setActionTimeline] + ); + return { + isDeleteTimelineModalOpen, + setIsDeleteTimelineModalOpen, + onCloseDeleteTimelineModal, + onOpenDeleteTimelineModal, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index 7eedba87b69fc..b6daa36e89b20 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -11,106 +11,47 @@ import { DeleteTimelineModalButton } from '.'; describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; + const defaultProps = { + closeModal: jest.fn(), + deleteTimelines: jest.fn(), + isModalOpen: true, + savedObjectIds: [savedObjectId], + title: 'Privilege Escalation', + }; describe('showModalState', () => { - test('it disables the delete icon if deleteTimelines is not provided', () => { - const wrapper = mountWithIntl( - - ); + test('it does NOT render the modal when isModalOpen is false', () => { + const testProps = { + ...defaultProps, + isModalOpen: false, + }; + const wrapper = mountWithIntl(); - const props = wrapper - .find('[data-test-subj="delete-timeline-wrapper"]') - .first() - .props(); - - expect(props.disabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is null', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline-wrapper"]') - .first() - .props(); - - expect(props.disabled).toBe(true); - }); - - test('it disables the delete icon if savedObjectId is an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline-wrapper"]') - .first() - .props(); - - expect(props.disabled).toBe(true); - }); - - test('it enables the delete icon if savedObjectId is NOT an empty string', () => { - const wrapper = mountWithIntl( - - ); - - const props = wrapper - .find('[data-test-subj="delete-timeline-wrapper"]') - .first() - .props(); - - expect(props.disabled).toBe(false); + expect( + wrapper + .find('[data-test-subj="delete-timeline-modal"]') + .first() + .exists() + ).toBe(false); }); - test('it does NOT render the modal when showModal is false', () => { - const wrapper = mountWithIntl( - - ); + test('it renders the modal when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper .find('[data-test-subj="delete-timeline-modal"]') .first() .exists() - ).toBe(false); + ).toBe(true); }); - test('it renders the modal when showModal is clicked', () => { - const wrapper = mountWithIntl( - - ); - - wrapper - .find('[data-test-subj="delete-timeline"]') - .first() - .simulate('click'); + test('it hides popover when isModalOpen is true', () => { + const wrapper = mountWithIntl(); expect( wrapper - .find('[data-test-subj="delete-timeline-modal"]') + .find('[data-test-subj="remove-popover"]') .first() .exists() ).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index a81a8d1b6573d..782804be01546 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -7,7 +7,6 @@ import { EuiModal, EuiOverlayMask, EuiIcon, EuiLink } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled, { createGlobalStyle } from 'styled-components'; import { DeleteTimelineModal, DELETE_TIMELINE_MODAL_WIDTH } from './delete_timeline_modal'; -import * as i18n from '../translations'; import { DeleteTimelines } from '../types'; const RemovePopover = createGlobalStyle` div.euiPopover__panel-isOpen { @@ -21,12 +20,12 @@ export const ActionListIcon = styled(EuiIcon)` margin-right: 8px; `; interface Props { + closeModal: () => void; deleteTimelines: DeleteTimelines; + onComplete?: () => void; isModalOpen: boolean; - closeModal: () => void; savedObjectIds?: string[] | null | undefined; title: string | JSX.Element | null; - onComplete?: () => void; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal @@ -48,7 +47,7 @@ export const DeleteTimelineModalButton = React.memo( }, [closeModal, deleteTimelines, savedObjectIds]); return ( <> - {isModalOpen && } + {isModalOpen && } {isModalOpen ? ( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx new file mode 100644 index 0000000000000..0fc8c135e08b0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState } from 'react'; +import { useExportTimeline, ExportTimeline } from './export_timeline/.'; +import { OpenTimelineResult, DeleteTimeline } from './types'; +import { useDeleteTimeline } from './delete_timeline_modal/delete_timeline_modal'; + +export const useEditTimelineActions = (selectedItems?: OpenTimelineResult[] | undefined) => { + const [actionItem, setActionTimeline] = useState(undefined); + + const deleteTimeline: DeleteTimeline = useDeleteTimeline({ + setActionTimeline, + }); + const exportTimeline: ExportTimeline = useExportTimeline({ + selectedItems: actionItem != null ? [actionItem] : selectedItems, + setActionTimeline, + }); + + return { + actionItem, + setActionTimeline, + ...deleteTimeline, + ...exportTimeline, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx new file mode 100644 index 0000000000000..fff7d8a14b7a8 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; +import React, { useCallback, Dispatch } from 'react'; +import { TimelineDownloader } from './export_timeline/export_timeline'; +import { DeleteTimelineModalButton } from './delete_timeline_modal'; +import * as i18n from './translations'; +import { DeleteTimeline, DeleteTimelines, OpenTimelineResult } from './types'; +import { ExportTimeline } from './export_timeline'; +import { useEditTimelineActions } from './edit_timeline_actions'; +import { ActionToaster } from '../toasters'; + +export const useEditTimelinBatcheActions = ({ + deleteTimelines, + dispatchToaster, + selectedItems, + tableRef, +}: { + deleteTimelines: DeleteTimelines | undefined; + dispatchToaster: Dispatch; + selectedItems: OpenTimelineResult[] | undefined; + tableRef: React.MutableRefObject | undefined>; +}) => { + const { + enableExportTimelineDownloader, + disableExportTimelineDownloader, + exportedIds, + getExportedData, + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCloseDeleteTimelineModal, + }: DeleteTimeline & ExportTimeline = useEditTimelineActions(selectedItems); + const onCompleteBatchActions = useCallback( + (closePopover?: () => void) => { + if (closePopover != null) closePopover(); + if (tableRef != null && tableRef.current != null) { + tableRef.current.changeSelection([]); + } + }, + [tableRef.current] + ); + + const getBatchItemsPopoverContent = useCallback( + (closePopover: () => void) => { + return ( + <> + + {deleteTimelines != null && ( + (item.savedObjectId != null ? [...acc, item.savedObjectId] : acc), + [] as string[] + )} + title={ + selectedItems?.length !== 1 + ? i18n.SELECTED_TIMELINES(selectedItems?.length ?? 0) + : `"${selectedItems[0]?.title}"` + } + /> + )} + { + enableExportTimelineDownloader(); + }} + > + {i18n.EXPORT_SELECTED} + , + { + onOpenDeleteTimelineModal(); + }} + > + {i18n.DELETE_SELECTED} + , + ]} + /> + + ); + }, + [ + deleteTimelines, + dispatchToaster, + disableExportTimelineDownloader, + exportedIds, + getExportedData, + isEnableDownloader, + isDeleteTimelineModalOpen, + selectedItems, + onCloseDeleteTimelineModal, + onCompleteBatchActions, + enableExportTimelineDownloader, + onOpenDeleteTimelineModal, + ] + ); + return { onCompleteBatchActions, getBatchItemsPopoverContent }; +}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx index 3c12b774ce0fd..63b5bb64c384e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -25,7 +25,14 @@ jest.mock('.', () => { describe('TimelineDownloader', () => { let wrapper: ReactWrapper; - describe('render without selected timeline', () => { + const defaultTestProps = { + disableExportTimelineDownloader: jest.fn(), + exportedIds: [{ timelineId: 'baa20980-6301-11ea-9223-95b6d4dd806c' }], + getExportedData: jest.fn(), + isEnableDownloader: true, + selectedItems: undefined, + }; + describe('should not render a downloader', () => { beforeAll(() => { ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ enableDownloader: false, @@ -33,68 +40,59 @@ describe('TimelineDownloader', () => { exportedIds: {}, getExportedData: jest.fn(), }); - wrapper = mount(); }); afterAll(() => { ((useExportTimeline as unknown) as jest.Mock).mockReset(); }); - test('Should render title', () => { - expect(wrapper.text()).toEqual('EXPORT_SELECTED'); - }); - - test('should render exportIcon', () => { - expect(wrapper.find('[data-test-subj="export-timeline-icon"]').exists()).toBeTruthy(); + test('Without selectedItems', () => { + const testProps = { + ...defaultTestProps, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); }); - test('should not be clickable', () => { - expect( - wrapper - .find('[data-test-subj="export-timeline"]') - .first() - .prop('disabled') - ).toBeTruthy(); + test('Without exportedIds', () => { + const testProps = { + ...defaultTestProps, + exportedIds: undefined, + }; + wrapper = mount(); + expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); }); - test('should not render a downloader', () => { + test('With isEnableDownloader is false', () => { + const testProps = { + ...defaultTestProps, + isEnableDownloader: false, + }; + wrapper = mount(); expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); }); }); - describe('render with a selected timeline', () => { + describe('should render a downloader', () => { beforeAll(() => { ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ - enableDownloader: true, + enableDownloader: false, setEnableDownloader: jest.fn(), exportedIds: {}, getExportedData: jest.fn(), }); - wrapper = mount(); }); afterAll(() => { ((useExportTimeline as unknown) as jest.Mock).mockReset(); }); - test('Should render title', () => { - expect(wrapper.text()).toEqual('EXPORT_SELECTED'); - }); - - test('should render exportIcon', () => { - expect(wrapper.find('[data-test-subj="export-timeline-icon"]').exists()).toBeTruthy(); - }); - - test('should be clickable', () => { - expect( - wrapper - .find('[data-test-subj="export-timeline"]') - .first() - .prop('disabled') - ).toBeFalsy(); - }); - - test('should render a downloader', () => { + test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { + const testProps = { + ...defaultTestProps, + selectedItems: mockSelectedTimeline, + }; + wrapper = mount(); expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeTruthy(); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx index f8e2c32eddbf7..23dd8e1cc884d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -10,12 +10,13 @@ import { OpenTimelineResult, DisableExportTimelineDownloader } from '../types'; import { GenericDownloader, ExportSelectedData } from '../../generic_downloader'; import * as i18n from '../translations'; import { useStateToaster } from '../../toasters'; +import { ExportTimelineIds } from '.'; const ExportTimeline: React.FC<{ isEnableDownloader: boolean; onDownloadComplete?: () => void; selectedItems: OpenTimelineResult[] | undefined; - exportedIds: string; + exportedIds: ExportTimelineIds[] | undefined; getExportedData: ExportSelectedData; disableExportTimelineDownloader: DisableExportTimelineDownloader; }> = ({ @@ -29,27 +30,33 @@ const ExportTimeline: React.FC<{ const [, dispatchToaster] = useStateToaster(); return ( <> - {selectedItems != null && exportedIds != null && isEnableDownloader && ( - { - disableExportTimelineDownloader(); - if (typeof onDownloadComplete === 'function') onDownloadComplete(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} - /> - )} + {selectedItems != null && + selectedItems.length !== 0 && + exportedIds != null && + isEnableDownloader && ( + { + disableExportTimelineDownloader(); + if (typeof onDownloadComplete === 'function') onDownloadComplete(); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }} + onExportFailure={() => { + disableExportTimelineDownloader(); + }} + /> + )} ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx index 057f9c435df91..113c460331b69 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -14,7 +14,10 @@ describe('useExportTimeline', () => { describe('call with selected timelines', () => { let exportTimelineRes: ExportTimeline; const TestHook = () => { - exportTimelineRes = useExportTimeline(mockSelectedTimeline); + exportTimelineRes = useExportTimeline({ + selectedItems: mockSelectedTimeline, + setActionTimeline: jest.fn(), + }); return
; }; @@ -23,7 +26,7 @@ describe('useExportTimeline', () => { }); test('Downloader should be disabled by default', () => { - expect(exportTimelineRes.enableDownloader).toBeFalsy(); + expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); }); test('exportedIds should include timelineId', () => { @@ -31,25 +34,15 @@ describe('useExportTimeline', () => { mockSelectedTimeline[0].savedObjectId ); }); - - test('exportedIds should include noteIds', () => { - expect(get('exportedIds[0].noteIds', exportTimelineRes)).toEqual([ - mockSelectedTimeline[0].notes[0].noteId, - mockSelectedTimeline[0].notes[1].noteId, - ]); - }); - - test('exportedIds should include pinnedEventIds', () => { - expect(get('exportedIds[0].pinnedEventIds', exportTimelineRes)).toEqual( - Object.keys(mockSelectedTimeline[0].pinnedEventIds) - ); - }); }); describe('call without selected timelines', () => { let exportTimelineRes: ExportTimeline; const TestHook = () => { - exportTimelineRes = useExportTimeline(undefined); + exportTimelineRes = useExportTimeline({ + selectedItems: undefined, + setActionTimeline: jest.fn(), + }); return
; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 234fa57c9ef9b..152ef005824be 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -4,29 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useCallback, Dispatch, SetStateAction } from 'react'; -import { keys } from 'lodash/fp'; -import { OpenTimelineResult } from '../types'; +import React, { useState, useCallback, Dispatch, SetStateAction } from 'react'; +import { + OpenTimelineResult, + DeleteTimelines, + DisableExportTimelineDownloader, + OnCloseDeleteTimelineModal, +} from '../types'; import { KibanaServices } from '../../../lib/kibana'; import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; - +import { TimelineDownloader } from './export_timeline'; +import { DeleteTimelineModalButton } from '../delete_timeline_modal'; +export interface ExportTimelineIds { + timelineId: string | null | undefined; +} export interface ExportTimeline { + disableExportTimelineDownloader: () => void; + enableExportTimelineDownloader: () => void; + exportedIds: ExportTimelineIds[] | undefined; + getExportedData: ExportSelectedData; isEnableDownloader: boolean; setIsEnableDownloader: Dispatch>; - exportedIds: - | Array<{ - timelineId: string | null | undefined; - pinnedEventIds: string[] | null | undefined; - noteIds: string[] | undefined; - }> - | undefined; - getExportedData: ExportSelectedData; } -export const useExportTimeline = ( - selectedItems?: OpenTimelineResult | OpenTimelineResult[] -): ExportTimeline => { +export const useExportTimeline = ({ + selectedItems, + setActionTimeline, +}: { + selectedItems?: OpenTimelineResult | OpenTimelineResult[]; + setActionTimeline: Dispatch>; +}): ExportTimeline => { const [isEnableDownloader, setIsEnableDownloader] = useState(false); const exportSelectedTimeline: ExportSelectedData = useCallback( async ({ @@ -57,21 +65,75 @@ export const useExportTimeline = ( const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; return array.map(item => ({ timelineId: item.savedObjectId, - pinnedEventIds: - item.pinnedEventIds != null ? keys(item.pinnedEventIds) : item.pinnedEventIds, - noteIds: item?.notes?.reduce( - (acc, note) => (note.noteId != null ? [...acc, note.noteId] : acc), - [] as string[] - ), })); }, [selectedItems] ); + const enableExportTimelineDownloader = useCallback( + (selectedActionItem?: OpenTimelineResult) => { + setIsEnableDownloader(true); + setActionTimeline(selectedActionItem); + }, + [setIsEnableDownloader, setActionTimeline] + ); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + }, [setIsEnableDownloader]); + return { - isEnableDownloader, - setIsEnableDownloader, + disableExportTimelineDownloader, + enableExportTimelineDownloader, exportedIds: selectedItems != null ? getExportedIds(selectedItems) : undefined, getExportedData: exportSelectedTimeline, + isEnableDownloader, + setIsEnableDownloader, }; }; + +const EditTimelineActionsComponent: React.FC<{ + actionItem: OpenTimelineResult | undefined; + deleteTimelines: DeleteTimelines | undefined; + disableExportTimelineDownloader: DisableExportTimelineDownloader; + exportedIds: ExportTimelineIds[] | undefined; + getExportedData: ExportSelectedData; + isEnableDownloader: boolean; + onCloseDeleteTimelineModal: OnCloseDeleteTimelineModal; + onCompleteBatchActions: () => void; + isDeleteTimelineModalOpen: boolean; +}> = ({ + actionItem, + deleteTimelines, + exportedIds, + getExportedData, + isEnableDownloader, + onCompleteBatchActions, + disableExportTimelineDownloader, + onCloseDeleteTimelineModal, + isDeleteTimelineModalOpen, +}) => + actionItem ? ( + <> + + {deleteTimelines != null && actionItem.savedObjectId && ( + + )} + + ) : null; + +export const EditTimelineActions = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index f4e87491625b2..17476d9d90b96 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel, EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; -import React, { useMemo, useCallback, useRef, useState } from 'react'; +import { EuiPanel, EuiBasicTable } from '@elastic/eui'; +import React, { useMemo, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps, OpenTimelineResult } from './types'; @@ -15,8 +15,7 @@ import { TitleRow } from './title_row'; import * as i18n from './translations'; import { useStateToaster } from '../toasters'; -import { TimelineDownloader } from './export_timeline/export_timeline'; -import { DeleteTimelineModalButton } from './delete_timeline_modal'; +import { EditTimelineActions } from './export_timeline/.'; import { UtilityBarGroup, UtilityBarText, @@ -24,13 +23,8 @@ import { UtilityBarSection, UtilityBarAction, } from '../utility_bar'; -import { useExportTimeline } from './export_timeline'; -export interface ExportTimelineIds { - timelineId: string | null | undefined; - pinnedEventIds: string[] | null | undefined; - noteIds: string[] | null | undefined; -} - +import { useEditTimelinBatcheActions } from './edit_timeline_batch_actions'; +import { useEditTimelineActions } from './edit_timeline_actions'; export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -57,40 +51,27 @@ export const OpenTimeline = React.memo( title, totalSearchResultsCount, }) => { - const tableRef = useRef(); + const tableRef = useRef>(); const [, dispatchToaster] = useStateToaster(); - const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); - const [actionItem, setActionTimeline] = useState(undefined); + const { - isEnableDownloader, - setIsEnableDownloader, + actionItem, + disableExportTimelineDownloader, + enableExportTimelineDownloader, exportedIds, getExportedData, - } = useExportTimeline(actionItem ? [actionItem] : selectedItems); - const enableExportTimelineDownloader = useCallback( - (selectedActionItem?: OpenTimelineResult) => { - setIsEnableDownloader(true); - setActionTimeline(selectedActionItem); - }, - [setIsEnableDownloader, setActionTimeline] - ); - - const disableExportTimelineDownloader = useCallback(() => { - setIsEnableDownloader(false); - }, [setIsEnableDownloader]); - - const onCloseDeleteTimelineModal = useCallback(() => { - setIsDeleteTimelineModalOpen(false); - setActionTimeline(undefined); - }, [setIsDeleteTimelineModalOpen]); + isEnableDownloader, + isDeleteTimelineModalOpen, + onOpenDeleteTimelineModal, + onCloseDeleteTimelineModal, + } = useEditTimelineActions(); - const onOpenDeleteTimelineModal = useCallback( - (selectedActionItem?: OpenTimelineResult) => { - setIsDeleteTimelineModalOpen(true); - setActionTimeline(selectedActionItem); - }, - [setIsDeleteTimelineModalOpen, setActionTimeline] - ); + const { onCompleteBatchActions, getBatchItemsPopoverContent } = useEditTimelinBatcheActions({ + deleteTimelines, + dispatchToaster, + selectedItems, + tableRef, + }); const nTimelines = useMemo( () => ( @@ -109,107 +90,21 @@ export const OpenTimeline = React.memo( ), [totalSearchResultsCount, query] ); - const onCompleteBatchActions = useCallback( - (closePopover?: () => void) => { - if (closePopover != null) closePopover(); - if (tableRef != null && tableRef.current != null) { - tableRef.current.changeSelection([]); - } - }, - [tableRef.current] - ); - - const getBatchItemsPopoverContent = useCallback( - (closePopover: () => void) => { - return ( - <> - - {deleteTimelines != null && ( - (item.savedObjectId != null ? [...acc, item.savedObjectId] : acc), - [] as string[] - )} - title={ - selectedItems.length > 1 - ? i18n.SELECTED_TIMELINES(selectedItems.length) - : `"${selectedItems[0]?.title}"` - } - /> - )} - { - enableExportTimelineDownloader(); - }} - > - {i18n.EXPORT_SELECTED} - , - { - onOpenDeleteTimelineModal(); - }} - > - {i18n.DELETE_SELECTED} - , - ]} - /> - - ); - }, - [ - actionItem, - deleteTimelines, - dispatchToaster, - history, - isEnableDownloader, - isDeleteTimelineModalOpen, - selectedItems, - onCloseDeleteTimelineModal, - onCompleteBatchActions, - exportedIds, - getExportedData, - ] - ); return ( <> - - {deleteTimelines != null && ( - - )} + { const openAsDuplicateColumn = { @@ -51,7 +51,7 @@ export const getActionsColumns = ({ name: i18n.EXPORT_SELECTED, icon: 'exportAction', onClick: (selectedTimeline: OpenTimelineResult) => { - enableExportTimelineDownloader(selectedTimeline); + if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); }, enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.EXPORT_SELECTED, @@ -61,10 +61,11 @@ export const getActionsColumns = ({ name: i18n.DELETE_SELECTED, icon: 'trash', onClick: (selectedTimeline: OpenTimelineResult) => { - onOpenDeleteTimelineModal(selectedTimeline); + if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); }, enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, description: i18n.DELETE_SELECTED, + 'data-test-subj': 'delete-timeline', }; return [ diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index 0f2cda9d79f0b..bb8f0dec09627 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -16,7 +16,7 @@ import { getEmptyValue } from '../../empty_value'; import { OpenTimelineResult } from '../types'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { NotePreviews } from '../note_previews'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; @@ -26,7 +26,26 @@ jest.mock('../../../lib/kibana'); describe('#getCommonColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - + const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, + }); beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); @@ -35,25 +54,13 @@ describe('#getCommonColumns', () => { test('it renders the expand button when the timelineResult has notes', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(true); @@ -62,25 +69,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are undefined', () => { const missingNotes: OpenTimelineResult[] = [omit('notes', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -89,25 +84,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult notes are null', () => { const nullNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: null }]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(nullNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -116,25 +99,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the notes are empty', () => { const emptylNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: [] }]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(emptylNotes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -144,26 +115,13 @@ describe('#getCommonColumns', () => { const missingSavedObjectId: OpenTimelineResult[] = [ omit('savedObjectId', { ...mockResults[0] }), ]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -172,25 +130,13 @@ describe('#getCommonColumns', () => { test('it does NOT render the expand button when the timelineResult savedObjectId is null', () => { const nullSavedObjectId: OpenTimelineResult[] = [{ ...mockResults[0], savedObjectId: null }]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(nullSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="expand-notes"]').exists()).toBe(false); @@ -199,25 +145,13 @@ describe('#getCommonColumns', () => { test('it renders the right arrow expander when the row is not expanded', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(hasNotes), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper @@ -235,26 +169,13 @@ describe('#getCommonColumns', () => { [mockResults[0].savedObjectId!]: , }; + const testProps: TimelinesTableProps = { + ...getDefaultProps(hasNotes), + itemIdToExpandedNotesRowMap, + }; const wrapper = mountWithIntl( - + ); @@ -275,25 +196,15 @@ describe('#getCommonColumns', () => { abc:
, }; + const testProps: TimelinesTableProps = { + ...getDefaultProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + + + ); wrapper @@ -317,26 +228,14 @@ describe('#getCommonColumns', () => { 'saved-timeline-11': , }; + const testProps: TimelinesTableProps = { + ...getDefaultProps(hasNotes), + itemIdToExpandedNotesRowMap, + onToggleShowNotes, + }; const wrapper = mountWithIntl( - + ); @@ -353,26 +252,12 @@ describe('#getCommonColumns', () => { describe('Timeline Name column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -385,26 +270,12 @@ describe('#getCommonColumns', () => { }); test('it renders the title when the timeline has a title and a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -421,25 +292,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -453,25 +312,13 @@ describe('#getCommonColumns', () => { test('it renders an Untitled Timeline title when the timeline has no title and a saved object id', () => { const missingTitle: OpenTimelineResult[] = [omit('title', { ...mockResults[0] })]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -487,25 +334,13 @@ describe('#getCommonColumns', () => { omit(['title', 'savedObjectId'], { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(withMissingSavedObjectIdAndTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -521,25 +356,13 @@ describe('#getCommonColumns', () => { { ...mockResults[0], title: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(withJustWhitespaceTitle), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -555,25 +378,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0], title: ' ' }), ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(withMissingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -587,24 +398,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -621,25 +415,13 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -653,26 +435,13 @@ describe('#getCommonColumns', () => { test('it invokes `onOpenTimeline` when the hyperlink is clicked', () => { const onOpenTimeline = jest.fn(); + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); @@ -692,24 +461,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -724,24 +476,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -758,24 +493,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( @@ -791,26 +509,12 @@ describe('#getCommonColumns', () => { { ...mockResults[0], description: ' ' }, ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(justWhitespaceDescription), + }; const wrapper = mountWithIntl( - + ); expect( @@ -826,24 +530,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -858,24 +545,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -893,24 +563,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index 4cbe1e45c473b..91a260b5ba5cc 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -15,7 +15,7 @@ import { getEmptyValue } from '../../empty_value'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; @@ -26,32 +26,39 @@ describe('#getExtendedColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; + const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, + }); + beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); describe('Modified By column', () => { test('it renders the expected column name', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -64,26 +71,12 @@ describe('#getExtendedColumns', () => { }); test('it renders the username when the timeline has an updatedBy property', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -97,27 +90,12 @@ describe('#getExtendedColumns', () => { test('it renders a placeholder when the timeline is missing the updatedBy property', () => { const missingUpdatedBy: OpenTimelineResult[] = [omit('updatedBy', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(missingUpdatedBy), + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 31377d176acac..78deded55e687 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -12,7 +12,7 @@ import { ThemeProvider } from 'styled-components'; import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; -import { TimelinesTable } from '.'; +import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; @@ -21,7 +21,26 @@ jest.mock('../../../lib/kibana'); describe('#getActionsColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - + const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, + }); beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); @@ -29,24 +48,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -55,26 +57,13 @@ describe('#getActionsColumns', () => { test('it renders the expected pinned events count', () => { const with6Events = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(with6Events), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="pinned-event-count"]').text()).toEqual('6'); @@ -83,24 +72,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -109,26 +81,13 @@ describe('#getActionsColumns', () => { test('it renders the expected notes count', () => { const with4Notes = [mockResults[0]]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(with4Notes), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="notes-count"]').text()).toEqual('4'); @@ -137,24 +96,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -163,26 +105,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is undefined', () => { const undefinedFavorite: OpenTimelineResult[] = [omit('favorite', { ...mockResults[0] })]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(undefinedFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -190,26 +119,13 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is null', () => { const nullFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: null }]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(nullFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); @@ -217,33 +133,20 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is empty', () => { const emptyFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: [] }]; - + const testProps: TimelinesTableProps = { + ...getDefaultProps(emptyFavorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starEmpty-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -255,32 +158,20 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); }); test('it renders an filled star when favorite has more than one entry', () => { - const emptyFavorite: OpenTimelineResult[] = [ + const favorite: OpenTimelineResult[] = [ { ...mockResults[0], favorite: [ @@ -296,25 +187,13 @@ describe('#getActionsColumns', () => { }, ]; + const testProps: TimelinesTableProps = { + ...getDefaultProps(favorite), + }; const wrapper = mountWithIntl( - + + + ); expect(wrapper.find('[data-test-subj="favorite-starFilled-star"]').exists()).toBe(true); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index 9463bf7de28c1..edd06f74f0330 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -23,6 +23,26 @@ jest.mock('../../../lib/kibana'); describe('TimelinesTable', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; + const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, + }); beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); @@ -31,24 +51,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -61,26 +64,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + actionTimelineToShow: ['delete', 'duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -93,26 +83,13 @@ describe('TimelinesTable', () => { }); test('it renders the Modified By column when showExtendedColumns is true ', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + showExtendedColumns: true, + }; const wrapper = mountWithIntl( - + ); @@ -125,33 +102,20 @@ describe('TimelinesTable', () => { }); test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); expect( wrapper .find('thead tr th') - .at(5) + .at(6) .find('[data-test-subj="notes-count-header-icon"]') .first() .exists() @@ -161,24 +125,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -191,26 +138,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + actionTimelineToShow: ['duplicate', 'selectable'], + }; const wrapper = mountWithIntl( - + ); @@ -225,24 +159,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -255,26 +172,13 @@ describe('TimelinesTable', () => { }); test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -288,27 +192,14 @@ describe('TimelinesTable', () => { test('it renders the default page size specified by the defaultPageSize prop', () => { const defaultPageSize = 123; - + const testProps = { + ...getDefaultProps(mockResults), + defaultPageSize, + pageSize: defaultPageSize, + }; const wrapper = mountWithIntl( - + ); @@ -323,24 +214,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -353,26 +227,13 @@ describe('TimelinesTable', () => { }); test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + showExtendedColumns: false, + }; const wrapper = mountWithIntl( - + ); @@ -385,25 +246,14 @@ describe('TimelinesTable', () => { }); test('it displays the expected message when no search results are found', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + searchResults: [], + }; const wrapper = mountWithIntl( - + + + ); expect( @@ -416,27 +266,13 @@ describe('TimelinesTable', () => { test('it invokes onTableChange with the expected parameters when a table header is clicked to sort it', () => { const onTableChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + onTableChange, + }; const wrapper = mountWithIntl( - + ); @@ -455,27 +291,13 @@ describe('TimelinesTable', () => { test('it invokes onSelectionChange when a row is selected', () => { const onSelectionChange = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + onSelectionChange, + }; const wrapper = mountWithIntl( - + ); @@ -490,26 +312,13 @@ describe('TimelinesTable', () => { }); test('it enables the table loading animation when isLoading is true', () => { + const testProps: TimelinesTableProps = { + ...getDefaultProps(mockResults), + loading: true, + }; const wrapper = mountWithIntl( - + ); @@ -524,24 +333,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx index e3466c30f685e..7091ef1f0a1f9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.tsx @@ -56,15 +56,14 @@ export const getTimelinesTableColumns = ({ itemIdToExpandedNotesRowMap, onOpenDeleteTimelineModal, onOpenTimeline, - onSelectionChange, onToggleShowNotes, showExtendedColumns, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; - enableExportTimelineDownloader: EnableExportTimelineDownloader; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; itemIdToExpandedNotesRowMap: Record; - onOpenDeleteTimelineModal: OnOpenDeleteTimelineModal; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; @@ -94,8 +93,8 @@ export interface TimelinesTableProps { defaultPageSize: number; loading: boolean; itemIdToExpandedNotesRowMap: Record; - enableExportTimelineDownloader: EnableExportTimelineDownloader; - onOpenDeleteTimelineModal: OnOpenDeleteTimelineModal; + enableExportTimelineDownloader?: EnableExportTimelineDownloader; + onOpenDeleteTimelineModal?: OnOpenDeleteTimelineModal; onOpenTimeline: OnOpenTimeline; onSelectionChange: OnSelectionChange; onTableChange: OnTableChange; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 4502156fcb383..9e3ad10ebf806 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/timeline/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../graphql/types'; @@ -78,8 +79,15 @@ export type OnOpenTimeline = ({ timelineId: string; }) => void; +export interface DeleteTimeline { + isDeleteTimelineModalOpen: boolean; + setIsDeleteTimelineModalOpen: Dispatch>; + onCloseDeleteTimelineModal: () => void; + onOpenDeleteTimelineModal: (selectedActionItem?: OpenTimelineResult | undefined) => void; +} export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; export type OnCloseDeleteTimelineModal = () => void; +export type SetActionTimeline = Dispatch>; export type DisableExportTimelineDownloader = () => void; export type EnableExportTimelineDownloader = (selectedItem: OpenTimelineResult) => void; /** Invoked when the user presses enters to submit the text in the search input */ diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 69dcb36e6898d..28a1b58361f55 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -5,7 +5,7 @@ */ import * as t from 'io-ts'; -import { ExportTimelineIds } from '../../../components/open_timeline/open_timeline'; +import { ExportTimelineIds } from '../../../components/open_timeline/export_timeline'; export const NewRuleSchema = t.intersection([ t.type({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 55bf08b680ce6..d38c0a848e5a2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -247,7 +247,7 @@ export const AllRules = React.memo( { + onExportSuccess={exportCount => { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ type: 'addToaster', diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index bd556e695e584..f483eadcd4e34 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -57,7 +57,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index 21a99afaab090..3521498f95dbb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -132,7 +132,7 @@ const RuleActionsOverflowComponent = ({ { + onExportSuccess={exportCount => { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), dispatchToaster From 981b243cdbe0afb4f04ee85e920f06f1f28ddeb7 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Sat, 14 Mar 2020 21:52:55 +0000 Subject: [PATCH 19/37] Update x-pack/legacy/plugins/siem/common/constants.ts Co-Authored-By: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> --- x-pack/legacy/plugins/siem/common/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/siem/common/constants.ts b/x-pack/legacy/plugins/siem/common/constants.ts index 305f5246ce41a..c3fc4aea77863 100644 --- a/x-pack/legacy/plugins/siem/common/constants.ts +++ b/x-pack/legacy/plugins/siem/common/constants.ts @@ -73,7 +73,7 @@ export const DETECTION_ENGINE_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL = `${DETECTION_ENGINE_RULES_URL}/prepackaged/_status`; export const TIMELINE_URL = '/api/timeline'; -export const TIMELINE_EXPORT_URL = '/api/timeline/_export'; +export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; /** * Default signals index key for kibana.dev.yml From 1d3713bacb1734dd0557a9a6b64796d0746b8ba8 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Sat, 14 Mar 2020 22:25:09 +0000 Subject: [PATCH 20/37] remove type ExportTimelineIds --- .../components/generic_downloader/index.tsx | 14 +++++--------- .../export_timeline/export_timeline.tsx | 3 +-- .../open_timeline/export_timeline/index.test.tsx | 2 +- .../open_timeline/export_timeline/index.tsx | 15 +++++++-------- .../pages/detection_engine/rules/all/index.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../components/rule_actions_overflow/index.tsx | 2 +- 7 files changed, 17 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 4de6b39eaa6f9..d710ec0e3d9eb 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -11,7 +11,6 @@ import * as i18n from './translations'; import { exportRules, ExportDocumentsProps } from '../../containers/detection_engine/rules'; import { useStateToaster, errorToToaster } from '../toasters'; -import { ExportTimelineIds } from '../open_timeline/export_timeline'; const InvisibleAnchor = styled.a` display: none; @@ -26,10 +25,9 @@ export type ExportSelectedData = ({ export interface GenericDownloaderProps { filename: string; - ids?: ExportTimelineIds[]; - ruleIds?: string[]; + ids?: string[]; exportSelectedData?: ExportSelectedData; - onExportSuccess: (exportCount: number) => void; + onExportSuccess?: (exportCount: number) => void; onExportFailure?: () => void; } @@ -45,7 +43,6 @@ export const GenericDownloaderComponent = ({ exportSelectedData, filename, ids, - ruleIds, onExportSuccess, onExportFailure, }: GenericDownloaderProps) => { @@ -60,13 +57,13 @@ export const GenericDownloaderComponent = ({ if ( anchorRef && anchorRef.current && - ((ruleIds != null && ruleIds.length > 0) || (ids != null && ids.length > 0)) + ((ids != null && ids.length > 0) || (ids != null && ids.length > 0)) ) { let exportResponse; try { if (isNil(exportSelectedData)) { exportResponse = await exportRules({ - ruleIds, + ids, signal: abortCtrl.signal, }); } else { @@ -89,8 +86,7 @@ export const GenericDownloaderComponent = ({ } if (onExportSuccess != null) { - if (ruleIds != null) onExportSuccess(ruleIds.length); - else if (ids != null) onExportSuccess(ids.length); + onExportSuccess(ids.length); } } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx index 23dd8e1cc884d..c4471819c1185 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -10,13 +10,12 @@ import { OpenTimelineResult, DisableExportTimelineDownloader } from '../types'; import { GenericDownloader, ExportSelectedData } from '../../generic_downloader'; import * as i18n from '../translations'; import { useStateToaster } from '../../toasters'; -import { ExportTimelineIds } from '.'; const ExportTimeline: React.FC<{ isEnableDownloader: boolean; onDownloadComplete?: () => void; selectedItems: OpenTimelineResult[] | undefined; - exportedIds: ExportTimelineIds[] | undefined; + exportedIds: string[] | undefined; getExportedData: ExportSelectedData; disableExportTimelineDownloader: DisableExportTimelineDownloader; }> = ({ diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx index 113c460331b69..7568568ac1f1c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -30,7 +30,7 @@ describe('useExportTimeline', () => { }); test('exportedIds should include timelineId', () => { - expect(get('exportedIds[0].timelineId', exportTimelineRes)).toEqual( + expect(get('exportedIds[0]', exportTimelineRes)).toEqual( mockSelectedTimeline[0].savedObjectId ); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 152ef005824be..4957aa272a475 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -16,13 +16,11 @@ import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { TimelineDownloader } from './export_timeline'; import { DeleteTimelineModalButton } from '../delete_timeline_modal'; -export interface ExportTimelineIds { - timelineId: string | null | undefined; -} + export interface ExportTimeline { disableExportTimelineDownloader: () => void; enableExportTimelineDownloader: () => void; - exportedIds: ExportTimelineIds[] | undefined; + exportedIds: string[] | undefined; getExportedData: ExportSelectedData; isEnableDownloader: boolean; setIsEnableDownloader: Dispatch>; @@ -63,9 +61,10 @@ export const useExportTimeline = ({ const getExportedIds = useCallback( (selectedTimelines: OpenTimelineResult | OpenTimelineResult[]) => { const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; - return array.map(item => ({ - timelineId: item.savedObjectId, - })); + return array.reduce( + (acc, item) => (item.savedObjectId ? [...acc, item.savedObjectId] : [...acc]), + [] as string[] + ); }, [selectedItems] ); @@ -96,7 +95,7 @@ const EditTimelineActionsComponent: React.FC<{ actionItem: OpenTimelineResult | undefined; deleteTimelines: DeleteTimelines | undefined; disableExportTimelineDownloader: DisableExportTimelineDownloader; - exportedIds: ExportTimelineIds[] | undefined; + exportedIds: string[] | undefined; getExportedData: ExportSelectedData; isEnableDownloader: boolean; onCloseDeleteTimelineModal: OnCloseDeleteTimelineModal; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index d38c0a848e5a2..d598eb213e6d2 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -246,7 +246,7 @@ export const AllRules = React.memo( <> { dispatch({ type: 'loadingRuleIds', ids: [], actionType: null }); dispatchToaster({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index f483eadcd4e34..85682dd1a3375 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -57,8 +57,8 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` `; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx index 3521498f95dbb..784d10e389686 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/index.tsx @@ -131,7 +131,7 @@ const RuleActionsOverflowComponent = ({ { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), From 1846c7ce23598e6c2f878b06866c13217d3dee5d Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Sun, 15 Mar 2020 01:45:30 +0000 Subject: [PATCH 21/37] reduce props --- .../components/generic_downloader/index.tsx | 4 +- .../delete_timeline_modal.tsx | 11 ++-- .../delete_timeline_modal/index.tsx | 15 ++--- .../open_timeline/edit_timeline_actions.tsx | 8 ++- .../edit_timeline_batch_actions.tsx | 13 ++-- .../export_timeline/export_timeline.tsx | 64 ++++++++----------- .../open_timeline/export_timeline/index.tsx | 26 ++------ .../open_timeline/open_timeline.tsx | 35 +++++----- .../open_timeline/search_row/index.tsx | 14 ++-- .../siem/server/lib/timeline/saved_object.ts | 28 +------- 10 files changed, 83 insertions(+), 135 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index d710ec0e3d9eb..fa42ec9a1f34e 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -53,7 +53,7 @@ export const GenericDownloaderComponent = ({ let isSubscribed = true; const abortCtrl = new AbortController(); - async function exportData() { + const exportData = async () => { if ( anchorRef && anchorRef.current && @@ -96,7 +96,7 @@ export const GenericDownloaderComponent = ({ } } } - } + }; exportData(); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 640fba5942a23..493bfaf5661f7 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -20,11 +20,12 @@ interface Props { export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px const getDeletedTitles = (title: string | JSX.Element | null | undefined) => { - if (title == null) return `"${i18n.UNTITLED_TIMELINE}"`; - - if (typeof title === 'string') { - return title.trim().length > 0 ? title.trim() : `"${i18n.UNTITLED_TIMELINE}"`; - } else return title; + if (title != null && React.isValidElement(title)) { + return title; + } else if (title != null && typeof title === 'string' && title.trim().length > 0) { + return title.trim(); + } + return i18n.UNTITLED_TIMELINE; }; /** diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 782804be01546..b35f41cb115d5 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -20,7 +20,6 @@ export const ActionListIcon = styled(EuiIcon)` margin-right: 8px; `; interface Props { - closeModal: () => void; deleteTimelines: DeleteTimelines; onComplete?: () => void; isModalOpen: boolean; @@ -30,27 +29,25 @@ interface Props { /** * Renders a button that when clicked, displays the `Delete Timeline` modal */ -export const DeleteTimelineModalButton = React.memo( - ({ closeModal, deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { +export const DeleteTimelineModalOverlay = React.memo( + ({ deleteTimelines, isModalOpen, savedObjectIds, title, onComplete }) => { const internalCloseModal = useCallback(() => { - closeModal(); if (onComplete != null) { onComplete(); } - }, [closeModal, onComplete]); + }, [onComplete]); const onDelete = useCallback(() => { if (deleteTimelines != null && savedObjectIds != null) { deleteTimelines(savedObjectIds); } - closeModal(); if (onComplete != null) onComplete(); - }, [closeModal, deleteTimelines, savedObjectIds]); + }, [deleteTimelines, savedObjectIds, onComplete]); return ( <> {isModalOpen && } {isModalOpen ? ( - + ( ); } ); -DeleteTimelineModalButton.displayName = 'DeleteTimelineModalButton'; +DeleteTimelineModalOverlay.displayName = 'DeleteTimelineModalOverlay'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx index 0fc8c135e08b0..6cb1c25d90fd6 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { useExportTimeline, ExportTimeline } from './export_timeline/.'; import { OpenTimelineResult, DeleteTimeline } from './types'; import { useDeleteTimeline } from './delete_timeline_modal/delete_timeline_modal'; @@ -20,8 +20,14 @@ export const useEditTimelineActions = (selectedItems?: OpenTimelineResult[] | un setActionTimeline, }); + const onCompleteEditTimelineAction = useCallback(() => { + deleteTimeline.onCloseDeleteTimelineModal(); + exportTimeline.disableExportTimelineDownloader(); + }, [deleteTimeline.onCloseDeleteTimelineModal, exportTimeline.disableExportTimelineDownloader]); + return { actionItem, + onCompleteEditTimelineAction, setActionTimeline, ...deleteTimeline, ...exportTimeline, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index fff7d8a14b7a8..df28160b98ba9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -7,14 +7,14 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import React, { useCallback, Dispatch } from 'react'; import { TimelineDownloader } from './export_timeline/export_timeline'; -import { DeleteTimelineModalButton } from './delete_timeline_modal'; +import { DeleteTimelineModalOverlay } from './delete_timeline_modal'; import * as i18n from './translations'; import { DeleteTimeline, DeleteTimelines, OpenTimelineResult } from './types'; import { ExportTimeline } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; import { ActionToaster } from '../toasters'; -export const useEditTimelinBatcheActions = ({ +export const useEditTimelinBatchActions = ({ deleteTimelines, dispatchToaster, selectedItems, @@ -41,6 +41,8 @@ export const useEditTimelinBatcheActions = ({ if (tableRef != null && tableRef.current != null) { tableRef.current.changeSelection([]); } + disableExportTimelineDownloader(); + onCloseDeleteTimelineModal(); }, [tableRef.current] ); @@ -53,13 +55,10 @@ export const useEditTimelinBatcheActions = ({ exportedIds={exportedIds} getExportedData={getExportedData} isEnableDownloader={isEnableDownloader} - onDownloadComplete={onCompleteBatchActions.bind(null, closePopover)} - selectedItems={selectedItems} - disableExportTimelineDownloader={disableExportTimelineDownloader} + onComplete={onCompleteBatchActions.bind(null, closePopover)} /> {deleteTimelines != null && ( - void; - selectedItems: OpenTimelineResult[] | undefined; + onComplete?: () => void; exportedIds: string[] | undefined; getExportedData: ExportSelectedData; - disableExportTimelineDownloader: DisableExportTimelineDownloader; -}> = ({ - onDownloadComplete, - isEnableDownloader, - disableExportTimelineDownloader, - selectedItems, - exportedIds, - getExportedData, -}) => { +}> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { const [, dispatchToaster] = useStateToaster(); return ( <> - {selectedItems != null && - selectedItems.length !== 0 && - exportedIds != null && - isEnableDownloader && ( - { - disableExportTimelineDownloader(); - if (typeof onDownloadComplete === 'function') onDownloadComplete(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} - onExportFailure={() => { - disableExportTimelineDownloader(); - }} - /> - )} + {exportedIds != null && isEnableDownloader && ( + { + if (onComplete != null) onComplete(); + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }} + onExportFailure={() => { + if (onComplete != null) onComplete(); + }} + /> + )} ); }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 4957aa272a475..6377400a1693e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -5,17 +5,12 @@ */ import React, { useState, useCallback, Dispatch, SetStateAction } from 'react'; -import { - OpenTimelineResult, - DeleteTimelines, - DisableExportTimelineDownloader, - OnCloseDeleteTimelineModal, -} from '../types'; +import { OpenTimelineResult, DeleteTimelines } from '../types'; import { KibanaServices } from '../../../lib/kibana'; import { ExportSelectedData } from '../../generic_downloader'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { TimelineDownloader } from './export_timeline'; -import { DeleteTimelineModalButton } from '../delete_timeline_modal'; +import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; export interface ExportTimeline { disableExportTimelineDownloader: () => void; @@ -94,23 +89,19 @@ export const useExportTimeline = ({ const EditTimelineActionsComponent: React.FC<{ actionItem: OpenTimelineResult | undefined; deleteTimelines: DeleteTimelines | undefined; - disableExportTimelineDownloader: DisableExportTimelineDownloader; exportedIds: string[] | undefined; getExportedData: ExportSelectedData; isEnableDownloader: boolean; - onCloseDeleteTimelineModal: OnCloseDeleteTimelineModal; - onCompleteBatchActions: () => void; isDeleteTimelineModalOpen: boolean; + onComplete: () => void; }> = ({ actionItem, deleteTimelines, exportedIds, getExportedData, isEnableDownloader, - onCompleteBatchActions, - disableExportTimelineDownloader, - onCloseDeleteTimelineModal, isDeleteTimelineModalOpen, + onComplete, }) => actionItem ? ( <> @@ -118,16 +109,13 @@ const EditTimelineActionsComponent: React.FC<{ exportedIds={exportedIds} getExportedData={getExportedData} isEnableDownloader={isEnableDownloader} - onDownloadComplete={onCompleteBatchActions} - selectedItems={[actionItem]} - disableExportTimelineDownloader={disableExportTimelineDownloader} + onComplete={onComplete} /> {deleteTimelines != null && actionItem.savedObjectId && ( - diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 17476d9d90b96..f278e7691482c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -5,7 +5,7 @@ */ import { EuiPanel, EuiBasicTable } from '@elastic/eui'; -import React, { useMemo, useRef } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; import { OpenTimelineProps, OpenTimelineResult } from './types'; @@ -23,7 +23,7 @@ import { UtilityBarSection, UtilityBarAction, } from '../utility_bar'; -import { useEditTimelinBatcheActions } from './edit_timeline_batch_actions'; +import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; export const OpenTimeline = React.memo( ({ @@ -56,17 +56,16 @@ export const OpenTimeline = React.memo( const { actionItem, - disableExportTimelineDownloader, enableExportTimelineDownloader, exportedIds, getExportedData, isEnableDownloader, isDeleteTimelineModalOpen, onOpenDeleteTimelineModal, - onCloseDeleteTimelineModal, + onCompleteEditTimelineAction, } = useEditTimelineActions(); - const { onCompleteBatchActions, getBatchItemsPopoverContent } = useEditTimelinBatcheActions({ + const { getBatchItemsPopoverContent } = useEditTimelinBatchActions({ deleteTimelines, dispatchToaster, selectedItems, @@ -94,15 +93,13 @@ export const OpenTimeline = React.memo( return ( <> @@ -144,9 +141,9 @@ export const OpenTimeline = React.memo( { + onClick={useCallback(() => { if (typeof refetch === 'function') refetch(); - }} + }, [refetch])} > {i18n.REFRESH} @@ -155,11 +152,13 @@ export const OpenTimeline = React.memo( + onDeleteSelected != null && deleteTimelines != null + ? ['delete', 'duplicate', 'export', 'selectable'] + : ['duplicate', 'export', 'selectable'], + [onDeleteSelected, deleteTimelines] + )} data-test-subj="timelines-table" deleteTimelines={deleteTimelines} defaultPageSize={defaultPageSize} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx index 07d4bbfeb2184..55fce1f1c1ed0 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/search_row/index.tsx @@ -37,6 +37,11 @@ type Props = Pick< 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' >; +const searchBox = { + placeholder: i18n.SEARCH_PLACEHOLDER, + incremental: false, +}; + /** * Renders the row containing the search input and Only Favorites filter */ @@ -46,14 +51,7 @@ export const SearchRow = React.memo( - + diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts index e3a141045e85f..88d7fcdb68164 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/saved_object.ts @@ -260,32 +260,6 @@ export class Timeline { ), }; } - - // public async exportTimeline(request: FrameworkRequest, timelineIds: string[]) { - // const savedObjectsClient = request.context.core.savedObjects.client; - // const savedObjects = await savedObjectsClient.bulkGet( - // timelineIds.map(id => ({ id, type: timelineSavedObjectType })) - // ); - // const timelinesWithNotesAndPinnedEvents = await Promise.all( - // savedObjects.saved_objects.map(async savedObject => { - // const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject); - // return Promise.all([ - // this.note.getNotesByTimelineId(request, timelineSaveObject.savedObjectId), - // this.pinnedEvent.getAllPinnedEventsByTimelineId( - // request, - // timelineSaveObject.savedObjectId - // ), - // Promise.resolve(timelineSaveObject), - // ]); - // }) - // ); - - // return { - // timeline: timelinesWithNotesAndPinnedEvents.map(([notes, pinnedEvents, timeline]) => - // timelineWithReduxProperties(notes, pinnedEvents, timeline) - // ), - // }; - // } } export const convertStringToBase64 = (text: string): string => Buffer.from(text).toString('base64'); @@ -301,7 +275,7 @@ export const timelineWithReduxProperties = ( notes: NoteSavedObject[], pinnedEvents: PinnedEventSavedObject[], timeline: TimelineSavedObject, - userName?: string + userName: string ): TimelineSavedObject => ({ ...timeline, favorite: From e6bf4eedaf4d46e1a4d671429cbfc7e4c06a504d Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 00:20:40 +0000 Subject: [PATCH 22/37] Get notes and pinned events by timeline id --- .../timeline/routes/export_timelines_route.ts | 120 ++---------- .../lib/timeline/routes/schemas/schemas.ts | 12 +- .../siem/server/lib/timeline/routes/utils.ts | 185 ++++++++++++++++++ .../plugins/siem/server/lib/timeline/types.ts | 10 +- 4 files changed, 208 insertions(+), 119 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index f3aab06ad8b83..14bd8a1b419fd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -5,22 +5,9 @@ */ import { set as _set } from 'lodash/fp'; -import { IRouter, SavedObjectsBulkResponse } from '../../../../../../../../src/core/server'; +import { IRouter } from '../../../../../../../../src/core/server'; import { LegacyServices } from '../../../types'; -import { - ExportTimelineRequestParams, - ExportTimelineSavedObjectsClient, - ExportTimelineRequest, - ExportedNotes, - TimelineSavedObject, - ExportedTimelines, - BulkGetInput, -} from '../types'; -import { timelineSavedObjectType, noteSavedObjectType } from '../../../saved_objects'; - -import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; -import { transformRulesToNdjson } from '../../detection_engine/routes/rules/utils'; -import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { ExportTimelineRequestParams } from '../types'; import { transformError, @@ -33,98 +20,8 @@ import { exportTimelinesSchema, exportTimelinesQuerySchema, } from './schemas/export_timelines_schema'; -import { NoteSavedObject } from '../../note/types'; - -const getExportTimelineByObjectIds = async ({ - client, - request, -}: { - client: ExportTimelineSavedObjectsClient; - request: ExportTimelineRequest; -}) => { - const timeline = await getTimelinesFromObjects(client, request); - return transformRulesToNdjson(timeline); -}; - -const getNotesByTimelineId = (notes: NoteSavedObject[] | undefined, timelineId: string) => { - const initialNotes: ExportedNotes = { - eventNotes: [], - globalNotes: [], - }; - - return ( - notes?.reduce((acc, note) => { - if (note.timelineId === timelineId) { - if (note.eventId == null) - return { - ...acc, - globalNotes: [...acc.globalNotes, note], - }; - else - return { - ...acc, - eventNotes: [...acc.eventNotes, note], - }; - } else return acc; - }, initialNotes) ?? initialNotes - ); -}; - -const getTimelinesFromObjects = async ( - client: ExportTimelineSavedObjectsClient, - request: ExportTimelineRequest -): Promise => { - const bulkGetTimelines = request.body.objects.map((item): { type: string; id: string } => ({ - id: item.timelineId, - type: timelineSavedObjectType, - })); - - const bulkGetNotes = request.body.objects.reduce((acc, item) => { - return item.noteIds.length > 0 - ? [ - ...acc, - ...item.noteIds?.map( - (noteId): BulkGetInput => ({ - id: noteId, - type: noteSavedObjectType, - }) - ), - ] - : acc; - }, [] as BulkGetInput[]); - - const savedObjects: [ - SavedObjectsBulkResponse | null, - SavedObjectsBulkResponse | null - ] = await Promise.all([ - bulkGetTimelines.length > 0 ? client.bulkGet(bulkGetTimelines) : Promise.resolve(null), - bulkGetNotes.length > 0 ? client.bulkGet(bulkGetNotes) : Promise.resolve(null), - ]); - - const timelineObjects: TimelineSavedObject[] | undefined = savedObjects[0] - ? savedObjects[0]?.saved_objects.map((savedObject: unknown) => { - return convertSavedObjectToSavedTimeline(savedObject); - }) - : undefined; - const noteObjects: NoteSavedObject[] | undefined = savedObjects[1] - ? savedObjects[1]?.saved_objects?.map((savedObject: unknown) => - convertSavedObjectToSavedNote(savedObject) - ) - : undefined; - - return ( - timelineObjects?.map(timeline => { - return { - ...timeline, - ...getNotesByTimelineId(noteObjects, timeline.savedObjectId), - pinnedEventIds: - request.body.objects.find(item => item.timelineId === timeline.savedObjectId) - ?.pinnedEventIds ?? [], - }; - }) ?? [] - ); -}; +import { getExportTimelineByObjectIds } from './utils'; export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['config']) => { router.post( @@ -141,8 +38,17 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co }, }, async (context, request, response) => { - const savedObjectsClient = context.core.savedObjects.client; const siemResponse = buildSiemResponse(response); + let savedObjectsClient; + try { + savedObjectsClient = context.core.savedObjects.client; + } catch (err) { + const error = transformError(err); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } try { const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index 45a48b28b95e3..b56574f08158d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -6,16 +6,8 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ -export const timelineId = Joi.string().required(); -export const pinnedEventIds = Joi.array().items(Joi.string()); -export const noteIds = Joi.array().items(Joi.string()); -export const objects = Joi.array().items( - Joi.object({ - timelineId, - pinnedEventIds, - noteIds, - }).required() -); + +export const objects = Joi.array().items(Joi.string()); export const exclude_export_details = Joi.boolean(); export const file_name = Joi.string(); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts new file mode 100644 index 0000000000000..9ea2bb3fa056d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { set as _set } from 'lodash/fp'; +import { SavedObjectsFindOptions } from '../../../../../../../../src/core/server'; + +import { + ExportTimelineSavedObjectsClient, + ExportTimelineRequest, + ExportedNotes, + TimelineSavedObject, + ExportedTimelines, + PinnedEventsByTimelineId, + NotesByTimelineId, +} from '../types'; +import { + timelineSavedObjectType, + noteSavedObjectType, + pinnedEventSavedObjectType, +} from '../../../saved_objects'; + +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { transformRulesToNdjson } from '../../detection_engine/routes/rules/utils'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; + +import { NoteSavedObject } from '../../note/types'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { PinnedEventSavedObject } from '../../pinned_event/types'; + +export const getExportTimelineByObjectIds = async ({ + client, + request, +}: { + client: ExportTimelineSavedObjectsClient; + request: ExportTimelineRequest; +}) => { + const timeline = await getTimelinesFromObjects(client, request); + return transformRulesToNdjson(timeline); +}; + +const getAllSavedNote = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + options: SavedObjectsFindOptions +): Promise => { + const savedObjects = await savedObjectsClient.find(options); + return savedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)); +}; + +const getNotesByTimelineId = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise => { + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + + return { [timelineId]: await Promise.resolve(getAllSavedNote(savedObjectsClient, options)) }; +}; + +const getGlobalEventNotesByTimelineId = ( + notes: NotesByTimelineId[], + timelineId: string +): ExportedNotes => { + const initialNotes: ExportedNotes = { + eventNotes: [], + globalNotes: [], + }; + const currentNotesRecord = notes.find(note => Object.keys(note)[0] === timelineId) ?? {}; + const currentNotes = currentNotesRecord[timelineId] ?? []; + + return ( + currentNotes?.reduce((acc, note) => { + if (note.eventId == null) + return { + ...acc, + globalNotes: [...acc.globalNotes, note], + }; + else + return { + ...acc, + eventNotes: [...acc.eventNotes, note], + }; + }, initialNotes) ?? initialNotes + ); +}; + +const getTimeline = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineIds: string[] +) => { + const savedObjects = await Promise.resolve( + savedObjectsClient.bulkGet( + timelineIds.reduce( + (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], + [] as Array<{ id: string; type: string }> + ) + ) + ); + + const timelineObjects: TimelineSavedObject[] | undefined = + savedObjects != null + ? savedObjects.saved_objects.map((savedObject: unknown) => { + return convertSavedObjectToSavedTimeline(savedObject); + }) + : []; + + return timelineObjects; +}; + +const getAllSavedPinnedEvents = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + options: SavedObjectsFindOptions +): Promise => { + const savedObjects = await savedObjectsClient.find(options); + + return savedObjects.saved_objects.map(savedObject => + convertSavedObjectToSavedPinnedEvent(savedObject) + ); +}; + +const getPinnedEventsByTimelineId = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineId: string +): Promise => { + const options: SavedObjectsFindOptions = { + type: pinnedEventSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], + }; + return { [timelineId]: await getAllSavedPinnedEvents(savedObjectsClient, options) }; +}; + +const getPinnedEventsIdsByTimelineId = ( + pinnedEvents: PinnedEventsByTimelineId[], + timelineId: string +): string[] => { + const currentPinnedEventsRecord = + pinnedEvents.find(pinnedEvent => Object.keys(pinnedEvent)[0] === timelineId) ?? {}; + const currentPinnedEvents = currentPinnedEventsRecord[timelineId] ?? []; + return currentPinnedEvents.map(event => event.eventId) ?? []; +}; + +const getTimelinesFromObjects = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + request: ExportTimelineRequest +): Promise => { + const timelines: TimelineSavedObject[] = await getTimeline( + savedObjectsClient, + request.body.objects + ); + + const notes: NotesByTimelineId[] = await Promise.all( + request.body.objects.reduce( + (acc, timelineId) => + timelineId != null + ? [...acc, getNotesByTimelineId(savedObjectsClient, timelineId)] + : [...acc], + [] as Array> + ) + ); + + const pinnedEvents: PinnedEventsByTimelineId[] = await Promise.all( + request.body.objects.reduce( + (acc, timelineId) => + timelineId != null + ? [...acc, getPinnedEventsByTimelineId(savedObjectsClient, timelineId)] + : [...acc], + [] as Array> + ) + ); + + return ( + timelines?.map(timeline => { + return { + ...timeline, + ...getGlobalEventNotesByTimelineId(notes, timeline.savedObjectId), + pinnedEventIds: getPinnedEventsIdsByTimelineId(pinnedEvents, timeline.savedObjectId), + }; + }) ?? [] + ); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index ce0cf0335ad2d..0f7efb07e2da6 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -10,7 +10,10 @@ import * as runtimeTypes from 'io-ts'; import { unionWithNullType } from '../framework'; import { NoteSavedObjectToReturnRuntimeType, NoteSavedObject } from '../note/types'; -import { PinnedEventToReturnSavedObjectRuntimeType } from '../pinned_event/types'; +import { + PinnedEventToReturnSavedObjectRuntimeType, + PinnedEventSavedObject, +} from '../pinned_event/types'; import { SavedObjectsClient, KibanaRequest } from '../../../../../../../src/core/server'; /* @@ -202,7 +205,7 @@ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} export interface ExportTimelineRequestParams { - body: { objects: Array<{ timelineId: string; noteIds: string[]; pinnedEventIds: string[] }> }; + body: { objects: string[] }; query: { file_name: string; exclude_export_details: boolean; @@ -246,3 +249,6 @@ export interface BulkGetInput { type: string; id: string; } + +export type PinnedEventsByTimelineId = Record; +export type NotesByTimelineId = Record; From 5aefe855be8adc86dc6bfaeb09408479ff2e007c Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 01:16:52 +0000 Subject: [PATCH 23/37] combine notes and pinned events data --- .../routes/rules/utils.test.ts | 12 +-- .../detection_engine/routes/rules/utils.ts | 8 +- .../detection_engine/rules/get_export_all.ts | 4 +- .../rules/get_export_by_object_ids.ts | 4 +- .../siem/server/lib/timeline/routes/utils.ts | 83 ++++++++++--------- .../plugins/siem/server/lib/timeline/types.ts | 6 +- 6 files changed, 63 insertions(+), 54 deletions(-) diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts index 70fcbb2c163ca..47cfa2696a054 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.test.ts @@ -12,7 +12,7 @@ import { transformTags, getIdBulkError, transformOrBulkError, - transformRulesToNdjson, + transformDataToNdjson, transformAlertsToRules, transformOrImportError, getDuplicates, @@ -968,15 +968,15 @@ describe('utils', () => { }); }); - describe('transformRulesToNdjson', () => { + describe('transformDataToNdjson', () => { test('if rules are empty it returns an empty string', () => { - const ruleNdjson = transformRulesToNdjson([]); + const ruleNdjson = transformDataToNdjson([]); expect(ruleNdjson).toEqual(''); }); test('single rule will transform with new line ending character for ndjson', () => { const rule = sampleRule(); - const ruleNdjson = transformRulesToNdjson([rule]); + const ruleNdjson = transformDataToNdjson([rule]); expect(ruleNdjson.endsWith('\n')).toBe(true); }); @@ -987,7 +987,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); // this is how we count characters in JavaScript :-) const count = ruleNdjson.split('\n').length - 1; expect(count).toBe(2); @@ -1000,7 +1000,7 @@ describe('utils', () => { result2.rule_id = 'some other id'; result2.name = 'Some other rule'; - const ruleNdjson = transformRulesToNdjson([result1, result2]); + const ruleNdjson = transformDataToNdjson([result1, result2]); const ruleStrings = ruleNdjson.split('\n'); const reParsed1 = JSON.parse(ruleStrings[0]); const reParsed2 = JSON.parse(ruleStrings[1]); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index ecf669b0106c3..38245245c96c2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -142,10 +142,10 @@ export const transformAlertToRule = ( }); }; -export const transformRulesToNdjson = (rules: Array>): string => { - if (rules.length !== 0) { - const rulesString = rules.map(rule => JSON.stringify(rule)).join('\n'); - return `${rulesString}\n`; +export const transformDataToNdjson = (data: Array>): string => { + if (data.length !== 0) { + const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); + return `${dataString}\n`; } else { return ''; } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts index 434919f80e149..6a27abb66ce85 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_all.ts @@ -7,7 +7,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; -import { transformAlertsToRules, transformRulesToNdjson } from '../routes/rules/utils'; +import { transformAlertsToRules, transformDataToNdjson } from '../routes/rules/utils'; export const getExportAll = async ( alertsClient: AlertsClient @@ -17,7 +17,7 @@ export const getExportAll = async ( }> => { const ruleAlertTypes = await getNonPackagedRules({ alertsClient }); const rules = transformAlertsToRules(ruleAlertTypes); - const rulesNdjson = transformRulesToNdjson(rules); + const rulesNdjson = transformDataToNdjson(rules); const exportDetails = getExportDetailsNdjson(rules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts index e3b38a879fc3d..6f642231ebbaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -8,7 +8,7 @@ import { AlertsClient } from '../../../../../../../plugins/alerting/server'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { isAlertType } from '../rules/types'; import { readRules } from './read_rules'; -import { transformRulesToNdjson, transformAlertToRule } from '../routes/rules/utils'; +import { transformDataToNdjson, transformAlertToRule } from '../routes/rules/utils'; import { OutputRuleAlertRest } from '../types'; interface ExportSuccesRule { @@ -37,7 +37,7 @@ export const getExportByObjectIds = async ( exportDetails: string; }> => { const rulesAndErrors = await getRulesFromObjects(alertsClient, objects); - const rulesNdjson = transformRulesToNdjson(rulesAndErrors.rules); + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); return { rulesNdjson, exportDetails }; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index 9ea2bb3fa056d..64753563d9385 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -12,8 +12,7 @@ import { ExportedNotes, TimelineSavedObject, ExportedTimelines, - PinnedEventsByTimelineId, - NotesByTimelineId, + NotesAndPinnedEventsByTimelineId, } from '../types'; import { timelineSavedObjectType, @@ -22,7 +21,7 @@ import { } from '../../../saved_objects'; import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; -import { transformRulesToNdjson } from '../../detection_engine/routes/rules/utils'; +import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; import { convertSavedObjectToSavedNote } from '../../note/saved_object'; import { NoteSavedObject } from '../../note/types'; @@ -37,7 +36,7 @@ export const getExportTimelineByObjectIds = async ({ request: ExportTimelineRequest; }) => { const timeline = await getTimelinesFromObjects(client, request); - return transformRulesToNdjson(timeline); + return transformDataToNdjson(timeline); }; const getAllSavedNote = async ( @@ -48,32 +47,41 @@ const getAllSavedNote = async ( return savedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)); }; -const getNotesByTimelineId = async ( +const getNotesByTimelineId = ( savedObjectsClient: ExportTimelineSavedObjectsClient, timelineId: string -): Promise => { +): Promise => { const options: SavedObjectsFindOptions = { type: noteSavedObjectType, search: timelineId, searchFields: ['timelineId'], }; - return { [timelineId]: await Promise.resolve(getAllSavedNote(savedObjectsClient, options)) }; + return getAllSavedNote(savedObjectsClient, options); }; -const getGlobalEventNotesByTimelineId = ( - notes: NotesByTimelineId[], +const getNotesAndPinnedEventsByTimelineId = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, timelineId: string -): ExportedNotes => { +): Promise => { + return { + [timelineId]: { + notes: await Promise.resolve(getNotesByTimelineId(savedObjectsClient, timelineId)), + pinnedEvents: await Promise.resolve( + getPinnedEventsByTimelineId(savedObjectsClient, timelineId) + ), + }, + }; +}; + +const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): ExportedNotes => { const initialNotes: ExportedNotes = { eventNotes: [], globalNotes: [], }; - const currentNotesRecord = notes.find(note => Object.keys(note)[0] === timelineId) ?? {}; - const currentNotes = currentNotesRecord[timelineId] ?? []; return ( - currentNotes?.reduce((acc, note) => { + currentNotes.reduce((acc, note) => { if (note.eventId == null) return { ...acc, @@ -88,7 +96,21 @@ const getGlobalEventNotesByTimelineId = ( ); }; -const getTimeline = async ( +const getExportedNotedandPinnedEvents = ( + data: NotesAndPinnedEventsByTimelineId[], + timelineId: string +) => { + const currentRecord = data.find(note => Object.keys(note)[0] === timelineId) ?? {}; + const currentNote = currentRecord[timelineId].notes ?? []; + const currentPinnedEvents = currentRecord[timelineId].pinnedEvents ?? []; + + return { + ...getGlobalEventNotesByTimelineId(currentNote), + pinnedEventIds: getPinnedEventsIdsByTimelineId(currentPinnedEvents), + }; +}; + +const getTimelines = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, timelineIds: string[] ) => { @@ -122,25 +144,21 @@ const getAllSavedPinnedEvents = async ( ); }; -const getPinnedEventsByTimelineId = async ( +const getPinnedEventsByTimelineId = ( savedObjectsClient: ExportTimelineSavedObjectsClient, timelineId: string -): Promise => { +): Promise => { const options: SavedObjectsFindOptions = { type: pinnedEventSavedObjectType, search: timelineId, searchFields: ['timelineId'], }; - return { [timelineId]: await getAllSavedPinnedEvents(savedObjectsClient, options) }; + return getAllSavedPinnedEvents(savedObjectsClient, options); }; const getPinnedEventsIdsByTimelineId = ( - pinnedEvents: PinnedEventsByTimelineId[], - timelineId: string + currentPinnedEvents: PinnedEventSavedObject[] ): string[] => { - const currentPinnedEventsRecord = - pinnedEvents.find(pinnedEvent => Object.keys(pinnedEvent)[0] === timelineId) ?? {}; - const currentPinnedEvents = currentPinnedEventsRecord[timelineId] ?? []; return currentPinnedEvents.map(event => event.eventId) ?? []; }; @@ -148,28 +166,18 @@ const getTimelinesFromObjects = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, request: ExportTimelineRequest ): Promise => { - const timelines: TimelineSavedObject[] = await getTimeline( + const timelines: TimelineSavedObject[] = await getTimelines( savedObjectsClient, request.body.objects ); - const notes: NotesByTimelineId[] = await Promise.all( - request.body.objects.reduce( - (acc, timelineId) => - timelineId != null - ? [...acc, getNotesByTimelineId(savedObjectsClient, timelineId)] - : [...acc], - [] as Array> - ) - ); - - const pinnedEvents: PinnedEventsByTimelineId[] = await Promise.all( + const notesAndPinnedEvents: NotesAndPinnedEventsByTimelineId[] = await Promise.all( request.body.objects.reduce( (acc, timelineId) => timelineId != null - ? [...acc, getPinnedEventsByTimelineId(savedObjectsClient, timelineId)] + ? [...acc, getNotesAndPinnedEventsByTimelineId(savedObjectsClient, timelineId)] : [...acc], - [] as Array> + [] as Array> ) ); @@ -177,8 +185,7 @@ const getTimelinesFromObjects = async ( timelines?.map(timeline => { return { ...timeline, - ...getGlobalEventNotesByTimelineId(notes, timeline.savedObjectId), - pinnedEventIds: getPinnedEventsIdsByTimelineId(pinnedEvents, timeline.savedObjectId), + ...getExportedNotedandPinnedEvents(notesAndPinnedEvents, timeline.savedObjectId), }; }) ?? [] ); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index 0f7efb07e2da6..bbf6c4a3e40b8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -250,5 +250,7 @@ export interface BulkGetInput { id: string; } -export type PinnedEventsByTimelineId = Record; -export type NotesByTimelineId = Record; +export type NotesAndPinnedEventsByTimelineId = Record< + string, + { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } +>; From 74b88fbe2ef38cd24613afa85727c9a25efecfe9 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 02:14:55 +0000 Subject: [PATCH 24/37] fix unit test --- .../delete_timeline_modal.tsx | 2 +- .../delete_timeline_modal/index.test.tsx | 8 +- .../export_timeline/export_timeline.test.tsx | 13 +- .../export_timeline/export_timeline.tsx | 4 +- .../routes/__mocks__/request_responses.ts | 211 +----------------- .../routes/export_timelines_route.test.ts | 19 +- .../siem/server/lib/timeline/routes/utils.ts | 21 +- 7 files changed, 39 insertions(+), 239 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 493bfaf5661f7..a32a4df8ce8a6 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -25,7 +25,7 @@ const getDeletedTitles = (title: string | JSX.Element | null | undefined) => { } else if (title != null && typeof title === 'string' && title.trim().length > 0) { return title.trim(); } - return i18n.UNTITLED_TIMELINE; + return `"${i18n.UNTITLED_TIMELINE}"`; }; /** diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx index b6daa36e89b20..6e0ba5ebe2425 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -7,7 +7,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; -import { DeleteTimelineModalButton } from '.'; +import { DeleteTimelineModalOverlay } from '.'; describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; @@ -25,7 +25,7 @@ describe('DeleteTimelineModal', () => { ...defaultProps, isModalOpen: false, }; - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect( wrapper @@ -36,7 +36,7 @@ describe('DeleteTimelineModal', () => { }); test('it renders the modal when isModalOpen is true', () => { - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect( wrapper @@ -47,7 +47,7 @@ describe('DeleteTimelineModal', () => { }); test('it hides popover when isModalOpen is true', () => { - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); expect( wrapper diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx index 63b5bb64c384e..d377b10a55c21 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -26,11 +26,10 @@ jest.mock('.', () => { describe('TimelineDownloader', () => { let wrapper: ReactWrapper; const defaultTestProps = { - disableExportTimelineDownloader: jest.fn(), - exportedIds: [{ timelineId: 'baa20980-6301-11ea-9223-95b6d4dd806c' }], + exportedIds: ['baa20980-6301-11ea-9223-95b6d4dd806c'], getExportedData: jest.fn(), isEnableDownloader: true, - selectedItems: undefined, + onComplete: jest.fn(), }; describe('should not render a downloader', () => { beforeAll(() => { @@ -46,14 +45,6 @@ describe('TimelineDownloader', () => { ((useExportTimeline as unknown) as jest.Mock).mockReset(); }); - test('Without selectedItems', () => { - const testProps = { - ...defaultTestProps, - }; - wrapper = mount(); - expect(wrapper.find('[data-test-subj="export-timeline-downloader"]').exists()).toBeFalsy(); - }); - test('Without exportedIds', () => { const testProps = { ...defaultTestProps, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx index 61dac0db90d2a..2fb67ec4ce7f8 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/export_timeline.tsx @@ -11,10 +11,10 @@ import * as i18n from '../translations'; import { useStateToaster } from '../../toasters'; const ExportTimeline: React.FC<{ - isEnableDownloader: boolean; - onComplete?: () => void; exportedIds: string[] | undefined; getExportedData: ExportSelectedData; + isEnableDownloader: boolean; + onComplete?: () => void; }> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { const [, dispatchToaster] = useStateToaster(); return ( diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index a5ac37249490f..364b976940ca1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -12,18 +12,7 @@ export const getExportTimelinesRequest = () => method: 'get', path: TIMELINE_EXPORT_URL, body: { - objects: [ - { - timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', - noteIds: ['eb3f3930-61dc-11ea-8a49-e77254c5b742'], - pinnedEventIds: [], - }, - { - timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', - noteIds: ['706e7510-5d52-11ea-8f07-0392944939c1'], - pinnedEventIds: ['6HW_eHABMQha2n6bHvQ0'], - }, - ], + objects: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], }, }); @@ -256,198 +245,6 @@ export const mockNotes = () => ({ ], }); -// export const getExportTimelines = () => [ -// { -// savedObjectId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', -// version: 'Wzk0OSwxXQ==', -// columns: [ -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: '@timestamp', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'message', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'event.category', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'event.action', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'host.name', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'source.ip', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'destination.ip', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'user.name', -// searchable: null, -// }, -// ], -// dataProviders: [], -// description: 'with a global note', -// eventType: 'raw', -// filters: [], -// kqlMode: 'filter', -// kqlQuery: { -// filterQuery: { -// kuery: { kind: 'kuery', expression: 'zeek.files.sha1 : * ' }, -// serializedQuery: -// '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', -// }, -// }, -// title: 'test no.2', -// dateRange: { start: 1582538951145, end: 1582625351145 }, -// savedQueryId: null, -// sort: { columnId: '@timestamp', sortDirection: 'desc' }, -// created: 1582625382448, -// createdBy: 'elastic', -// updated: 1583741197521, -// updatedBy: 'elastic', -// eventNotes: [], -// globalNotes: [ -// { -// noteId: 'eb3f3930-61dc-11ea-8a49-e77254c5b742', -// version: 'Wzk1MCwxXQ==', -// note: 'Global note', -// timelineId: 'f0e58720-57b6-11ea-b88d-3f1a31716be8', -// created: 1583741205473, -// createdBy: 'elastic', -// updated: 1583741205473, -// updatedBy: 'elastic', -// }, -// ], -// pinnedEventIds: [], -// }, -// { -// savedObjectId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', -// version: 'Wzk0NywxXQ==', -// columns: [ -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: '@timestamp', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'message', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'event.category', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'event.action', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'host.name', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'source.ip', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'destination.ip', -// searchable: null, -// }, -// { -// indexes: null, -// name: null, -// columnHeaderType: 'not-filtered', -// id: 'user.name', -// searchable: null, -// }, -// ], -// dataProviders: [], -// description: 'with an event note', -// eventType: 'raw', -// filters: [], -// kqlMode: 'filter', -// kqlQuery: { -// filterQuery: { -// serializedQuery: -// '{"bool":{"should":[{"exists":{"field":"zeek.files.sha1"}}],"minimum_should_match":1}}', -// kuery: { expression: 'zeek.files.sha1 : * ', kind: 'kuery' }, -// }, -// }, -// title: 'test no.3', -// dateRange: { start: 1582538951145, end: 1582625351145 }, -// savedQueryId: null, -// sort: { columnId: '@timestamp', sortDirection: 'desc' }, -// created: 1582642817439, -// createdBy: 'elastic', -// updated: 1583741175216, -// updatedBy: 'elastic', -// eventNotes: [ -// { -// noteId: '706e7510-5d52-11ea-8f07-0392944939c1', -// version: 'WzEwMiwxXQ==', -// eventId: '6HW_eHABMQha2n6bHvQ0', -// note: 'this is a note!!', -// timelineId: '890b8ae0-57df-11ea-a7c9-3976b7f1cb37', -// created: 1583241924223, -// createdBy: 'elastic', -// updated: 1583241924223, -// updatedBy: 'elastic', -// }, -// ], -// globalNotes: [], -// pinnedEventIds: ['6HW_eHABMQha2n6bHvQ0'], -// }, -// ]; +export const mockPinnedEvents = () => ({ + saved_objects: [], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts index c764afe5b4a54..d445533274a75 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -8,7 +8,7 @@ import { mockTimelines, mockNotes, mockTimelinesSavedObjects, - mockNotesSavedObjects, + mockPinnedEvents, getExportTimelinesRequest, } from './__mocks__/request_responses'; import { exportTimelinesRoute } from './export_timelines_route'; @@ -18,8 +18,9 @@ import { requestMock, } from '../../detection_engine/routes/__mocks__'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; -import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; +import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; jest.mock('../convert_saved_object_to_savedtimeline', () => { return { convertSavedObjectToSavedTimeline: jest.fn(), @@ -31,6 +32,12 @@ jest.mock('../../note/saved_object', () => { convertSavedObjectToSavedNote: jest.fn(), }; }); + +jest.mock('../../pinned_event/saved_object', () => { + return { + convertSavedObjectToSavedPinnedEvent: jest.fn(), + }; +}); describe('export timelines', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); @@ -47,11 +54,13 @@ describe('export timelines', () => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - clients.savedObjectsClient.bulkGet - .mockResolvedValueOnce(mockTimelinesSavedObjects()) - .mockResolvedValueOnce(mockNotesSavedObjects()); // successful status search + clients.savedObjectsClient.bulkGet.mockResolvedValue(mockTimelinesSavedObjects()); + ((convertSavedObjectToSavedTimeline as unknown) as jest.Mock).mockReturnValue(mockTimelines()); ((convertSavedObjectToSavedNote as unknown) as jest.Mock).mockReturnValue(mockNotes()); + ((convertSavedObjectToSavedPinnedEvent as unknown) as jest.Mock).mockReturnValue( + mockPinnedEvents() + ); exportTimelinesRoute(server.router, config); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index 64753563d9385..85de71643495b 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -20,12 +20,11 @@ import { pinnedEventSavedObjectType, } from '../../../saved_objects'; +import { convertSavedObjectToSavedNote } from '../../note/saved_object'; +import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils'; -import { convertSavedObjectToSavedNote } from '../../note/saved_object'; - import { NoteSavedObject } from '../../note/types'; -import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; import { PinnedEventSavedObject } from '../../pinned_event/types'; export const getExportTimelineByObjectIds = async ({ @@ -44,7 +43,9 @@ const getAllSavedNote = async ( options: SavedObjectsFindOptions ): Promise => { const savedObjects = await savedObjectsClient.find(options); - return savedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)); + return savedObjects != null + ? savedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)) + : []; }; const getNotesByTimelineId = ( @@ -101,8 +102,8 @@ const getExportedNotedandPinnedEvents = ( timelineId: string ) => { const currentRecord = data.find(note => Object.keys(note)[0] === timelineId) ?? {}; - const currentNote = currentRecord[timelineId].notes ?? []; - const currentPinnedEvents = currentRecord[timelineId].pinnedEvents ?? []; + const currentNote = currentRecord[timelineId]?.notes ?? []; + const currentPinnedEvents = currentRecord[timelineId]?.pinnedEvents ?? []; return { ...getGlobalEventNotesByTimelineId(currentNote), @@ -139,9 +140,11 @@ const getAllSavedPinnedEvents = async ( ): Promise => { const savedObjects = await savedObjectsClient.find(options); - return savedObjects.saved_objects.map(savedObject => - convertSavedObjectToSavedPinnedEvent(savedObject) - ); + return savedObjects != null + ? savedObjects.saved_objects.map(savedObject => + convertSavedObjectToSavedPinnedEvent(savedObject) + ) + : []; }; const getPinnedEventsByTimelineId = ( From a8cbeeceb0cb95dbc2c6bb28e1d48be297145b8c Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 09:31:15 +0000 Subject: [PATCH 25/37] fix type error --- .../containers/detection_engine/rules/api.test.ts | 10 +++++----- .../public/containers/detection_engine/rules/api.ts | 6 ++---- .../public/containers/detection_engine/rules/types.ts | 4 +--- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts index 3048fc3dc5a02..8fdc6a67f7d71 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.test.ts @@ -402,7 +402,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules', async () => { await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -419,7 +419,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with excludeExportDetails', async () => { await exportRules({ excludeExportDetails: true, - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -436,7 +436,7 @@ describe('Detections Rules API', () => { test('check parameter url, body and query when exporting rules with fileName', async () => { await exportRules({ filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -454,7 +454,7 @@ describe('Detections Rules API', () => { await exportRules({ excludeExportDetails: true, filename: 'myFileName.ndjson', - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/_export', { @@ -470,7 +470,7 @@ describe('Detections Rules API', () => { test('happy path', async () => { const resp = await exportRules({ - ruleIds: ['mySuperRuleId', 'mySuperRuleId_II'], + ids: ['mySuperRuleId', 'mySuperRuleId_II'], signal: abortCtrl.signal, }); expect(resp).toEqual(blob); diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index 2d9524ff486ba..126de9762a696 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -233,13 +233,11 @@ export const importRules = async ({ export const exportRules = async ({ excludeExportDetails = false, filename = `${i18n.EXPORT_FILENAME}.ndjson`, - ruleIds = [], + ids = [], signal, }: ExportDocumentsProps): Promise => { const body = - ruleIds.length > 0 - ? JSON.stringify({ objects: ruleIds.map(rule => ({ rule_id: rule })) }) - : undefined; + ids.length > 0 ? JSON.stringify({ objects: ids.map(rule => ({ rule_id: rule })) }) : undefined; return KibanaServices.get().http.fetch(`${DETECTION_ENGINE_RULES_URL}/_export`, { method: 'POST', diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index 28a1b58361f55..66a03ca9a3963 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -5,7 +5,6 @@ */ import * as t from 'io-ts'; -import { ExportTimelineIds } from '../../../components/open_timeline/export_timeline'; export const NewRuleSchema = t.intersection([ t.type({ @@ -180,8 +179,7 @@ export interface ImportRulesResponse { } export interface ExportDocumentsProps { - ids?: ExportTimelineIds[] | undefined; - ruleIds?: string[]; + ids: string[]; filename?: string; excludeExportDetails?: boolean; signal: AbortSignal; From 8f9a167e96e26a29c2f0ce4846998d7996562c50 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 12:09:57 +0000 Subject: [PATCH 26/37] fix type error --- .../siem/public/components/open_timeline/open_timeline.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index f278e7691482c..057a7d1a6c868 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -123,9 +123,7 @@ export const OpenTimeline = React.memo( - <> - {i18n.SHOWING} {nTimelines} - + {`${i18n.SHOWING} ${nTimelines}`} From 9029f8ec5857e574f16ff2305f5e2ba919cee481 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 12:42:13 +0000 Subject: [PATCH 27/37] fix unit tests --- .../siem/public/components/open_timeline/open_timeline.tsx | 4 +++- .../siem/public/components/utility_bar/utility_bar_text.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 057a7d1a6c868..f278e7691482c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -123,7 +123,9 @@ export const OpenTimeline = React.memo( - {`${i18n.SHOWING} ${nTimelines}`} + <> + {i18n.SHOWING} {nTimelines} + diff --git a/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx index 95e12518155a8..b7815b59f03f5 100644 --- a/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx +++ b/x-pack/legacy/plugins/siem/public/components/utility_bar/utility_bar_text.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { BarText } from './styles'; export interface UtilityBarTextProps { - children: string; + children: string | JSX.Element; dataTestSubj?: string; } From b83073dbede9025d16eb0f29fee4cd75f96b2a70 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Mon, 16 Mar 2020 21:08:22 +0000 Subject: [PATCH 28/37] fix for review --- .../edit_timeline_batch_actions.tsx | 5 +- .../open_timeline/export_timeline/index.tsx | 31 +-------- .../open_timeline/open_timeline.tsx | 4 +- .../public/containers/timeline/all/api.ts | 30 ++++++++ .../public/containers/timeline/all/index.tsx | 12 +++- .../siem/server/lib/timeline/routes/utils.ts | 68 +++++++++---------- 6 files changed, 80 insertions(+), 70 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index df28160b98ba9..a239e8e158768 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -13,6 +13,7 @@ import { DeleteTimeline, DeleteTimelines, OpenTimelineResult } from './types'; import { ExportTimeline } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; import { ActionToaster } from '../toasters'; +import { exportSelectedTimeline } from '../../containers/timeline/all/api'; export const useEditTimelinBatchActions = ({ deleteTimelines, @@ -29,7 +30,6 @@ export const useEditTimelinBatchActions = ({ enableExportTimelineDownloader, disableExportTimelineDownloader, exportedIds, - getExportedData, isEnableDownloader, isDeleteTimelineModalOpen, onOpenDeleteTimelineModal, @@ -53,7 +53,7 @@ export const useEditTimelinBatchActions = ({ <> @@ -105,7 +105,6 @@ export const useEditTimelinBatchActions = ({ dispatchToaster, disableExportTimelineDownloader, exportedIds, - getExportedData, isEnableDownloader, isDeleteTimelineModalOpen, selectedItems, diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 6377400a1693e..c0c98154faaa2 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -6,17 +6,16 @@ import React, { useState, useCallback, Dispatch, SetStateAction } from 'react'; import { OpenTimelineResult, DeleteTimelines } from '../types'; -import { KibanaServices } from '../../../lib/kibana'; import { ExportSelectedData } from '../../generic_downloader'; -import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + import { TimelineDownloader } from './export_timeline'; import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; +import { exportSelectedTimeline } from '../../../containers/timeline/all/api'; export interface ExportTimeline { disableExportTimelineDownloader: () => void; enableExportTimelineDownloader: () => void; exportedIds: string[] | undefined; - getExportedData: ExportSelectedData; isEnableDownloader: boolean; setIsEnableDownloader: Dispatch>; } @@ -29,29 +28,6 @@ export const useExportTimeline = ({ setActionTimeline: Dispatch>; }): ExportTimeline => { const [isEnableDownloader, setIsEnableDownloader] = useState(false); - const exportSelectedTimeline: ExportSelectedData = useCallback( - async ({ - excludeExportDetails = false, - filename = `timelines_export.ndjson`, - ids = [], - signal, - }): Promise => { - const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; - const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { - method: 'POST', - body, - query: { - exclude_export_details: excludeExportDetails, - file_name: filename, - }, - signal, - asResponse: true, - }); - - return response.body!; - }, - [selectedItems] - ); const getExportedIds = useCallback( (selectedTimelines: OpenTimelineResult | OpenTimelineResult[]) => { @@ -80,7 +56,6 @@ export const useExportTimeline = ({ disableExportTimelineDownloader, enableExportTimelineDownloader, exportedIds: selectedItems != null ? getExportedIds(selectedItems) : undefined, - getExportedData: exportSelectedTimeline, isEnableDownloader, setIsEnableDownloader, }; @@ -107,7 +82,7 @@ const EditTimelineActionsComponent: React.FC<{ <> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index f278e7691482c..9fee1bee15841 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -25,6 +25,7 @@ import { } from '../utility_bar'; import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; +import { exportSelectedTimeline } from '../../containers/timeline/all/api'; export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -58,7 +59,6 @@ export const OpenTimeline = React.memo( actionItem, enableExportTimelineDownloader, exportedIds, - getExportedData, isEnableDownloader, isDeleteTimelineModalOpen, onOpenDeleteTimelineModal, @@ -96,7 +96,7 @@ export const OpenTimeline = React.memo( actionItem={actionItem} deleteTimelines={deleteTimelines} exportedIds={exportedIds} - getExportedData={getExportedData} + getExportedData={exportSelectedTimeline} isDeleteTimelineModalOpen={isDeleteTimelineModalOpen} isEnableDownloader={isEnableDownloader} onComplete={onCompleteEditTimelineAction} diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts new file mode 100644 index 0000000000000..4374598e41fe2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/api.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaServices } from '../../../lib/kibana'; +import { ExportSelectedData } from '../../../components/generic_downloader'; +import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; + +export const exportSelectedTimeline: ExportSelectedData = async ({ + excludeExportDetails = false, + filename = `timelines_export.ndjson`, + ids = [], + signal, +}): Promise => { + const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; + const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { + method: 'POST', + body, + query: { + exclude_export_details: excludeExportDetails, + file_name: filename, + }, + signal, + asResponse: true, + }); + + return response.body!; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx index f77752ab14048..b5c91ca287f0b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/timeline/all/index.tsx @@ -3,13 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import React, { useCallback } from 'react'; import { getOr } from 'lodash/fp'; -import React from 'react'; import memoizeOne from 'memoize-one'; import { Query } from 'react-apollo'; +import { ApolloQueryResult } from 'apollo-client'; import { OpenTimelineResult } from '../../../components/open_timeline/types'; import { GetAllTimeline, @@ -37,6 +37,10 @@ interface OwnProps extends AllTimelinesVariables { children?: (args: AllTimelinesArgs) => React.ReactNode; } +type Refetch = ( + variables: GetAllTimeline.Variables | undefined +) => Promise>; + const getAllTimeline = memoizeOne( (variables: string, timelines: TimelineResult[]): OpenTimelineResult[] => timelines.map(timeline => ({ @@ -85,6 +89,8 @@ const AllTimelinesQueryComponent: React.FC = ({ search, sort, }; + const handleRefetch = useCallback((refetch: Refetch) => refetch(variables), [variables]); + return ( query={allTimelinesQuery} @@ -95,7 +101,7 @@ const AllTimelinesQueryComponent: React.FC = ({ {({ data, loading, refetch }) => children!({ loading, - refetch, + refetch: handleRefetch.bind(null, refetch), totalCount: getOr(0, 'getAllTimeline.totalCount', data), timelines: getAllTimeline( JSON.stringify(variables), diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index 85de71643495b..ef6a44e85c3b1 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -27,17 +27,6 @@ import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils import { NoteSavedObject } from '../../note/types'; import { PinnedEventSavedObject } from '../../pinned_event/types'; -export const getExportTimelineByObjectIds = async ({ - client, - request, -}: { - client: ExportTimelineSavedObjectsClient; - request: ExportTimelineRequest; -}) => { - const timeline = await getTimelinesFromObjects(client, request); - return transformDataToNdjson(timeline); -}; - const getAllSavedNote = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, options: SavedObjectsFindOptions @@ -111,29 +100,6 @@ const getExportedNotedandPinnedEvents = ( }; }; -const getTimelines = async ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - timelineIds: string[] -) => { - const savedObjects = await Promise.resolve( - savedObjectsClient.bulkGet( - timelineIds.reduce( - (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], - [] as Array<{ id: string; type: string }> - ) - ) - ); - - const timelineObjects: TimelineSavedObject[] | undefined = - savedObjects != null - ? savedObjects.saved_objects.map((savedObject: unknown) => { - return convertSavedObjectToSavedTimeline(savedObject); - }) - : []; - - return timelineObjects; -}; - const getAllSavedPinnedEvents = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, options: SavedObjectsFindOptions @@ -165,6 +131,29 @@ const getPinnedEventsIdsByTimelineId = ( return currentPinnedEvents.map(event => event.eventId) ?? []; }; +const getTimelines = async ( + savedObjectsClient: ExportTimelineSavedObjectsClient, + timelineIds: string[] +) => { + const savedObjects = await Promise.resolve( + savedObjectsClient.bulkGet( + timelineIds.reduce( + (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], + [] as Array<{ id: string; type: string }> + ) + ) + ); + + const timelineObjects: TimelineSavedObject[] | undefined = + savedObjects != null + ? savedObjects.saved_objects.map((savedObject: unknown) => { + return convertSavedObjectToSavedTimeline(savedObject); + }) + : []; + + return timelineObjects; +}; + const getTimelinesFromObjects = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, request: ExportTimelineRequest @@ -193,3 +182,14 @@ const getTimelinesFromObjects = async ( }) ?? [] ); }; + +export const getExportTimelineByObjectIds = async ({ + client, + request, +}: { + client: ExportTimelineSavedObjectsClient; + request: ExportTimelineRequest; +}) => { + const timeline = await getTimelinesFromObjects(client, request); + return transformDataToNdjson(timeline); +}; From 42ac248a53955cc6b99bcdc13bd0251d9df8ce15 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Tue, 17 Mar 2020 15:04:33 +0000 Subject: [PATCH 29/37] clean up generic downloader --- .../components/generic_downloader/index.test.tsx | 6 +++++- .../public/components/generic_downloader/index.tsx | 11 +++-------- .../public/pages/detection_engine/rules/all/index.tsx | 2 ++ .../__snapshots__/index.test.tsx.snap | 1 + .../rules/components/rule_actions_overflow/index.tsx | 3 ++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx index 5d8c3fa09f5e5..a70772911ba60 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.test.tsx @@ -11,7 +11,11 @@ import { GenericDownloaderComponent } from './index'; describe('GenericDownloader', () => { test('renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index fa42ec9a1f34e..382bcf47abeeb 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -9,7 +9,7 @@ import styled from 'styled-components'; import { isFunction, isNil } from 'lodash/fp'; import * as i18n from './translations'; -import { exportRules, ExportDocumentsProps } from '../../containers/detection_engine/rules'; +import { ExportDocumentsProps } from '../../containers/detection_engine/rules'; import { useStateToaster, errorToToaster } from '../toasters'; const InvisibleAnchor = styled.a` @@ -26,7 +26,7 @@ export type ExportSelectedData = ({ export interface GenericDownloaderProps { filename: string; ids?: string[]; - exportSelectedData?: ExportSelectedData; + exportSelectedData: ExportSelectedData; onExportSuccess?: (exportCount: number) => void; onExportFailure?: () => void; } @@ -61,12 +61,7 @@ export const GenericDownloaderComponent = ({ ) { let exportResponse; try { - if (isNil(exportSelectedData)) { - exportResponse = await exportRules({ - ids, - signal: abortCtrl.signal, - }); - } else { + if (!isNil(exportSelectedData)) { exportResponse = await exportSelectedData({ ids, signal: abortCtrl.signal, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index d598eb213e6d2..621c70e391319 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -22,6 +22,7 @@ import { FilterOptions, Rule, PaginationOptions, + exportRules, } from '../../../../containers/detection_engine/rules'; import { HeaderSection } from '../../../../components/header_section'; import { @@ -259,6 +260,7 @@ export const AllRules = React.memo( }, }); }} + exportSelectedData={exportRules} /> diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap index 85682dd1a3375..65a606604d4a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_actions_overflow/__snapshots__/index.test.tsx.snap @@ -56,6 +56,7 @@ exports[`RuleActionsOverflow renders correctly against snapshot 1`] = ` /> { displaySuccessToast( i18nActions.SUCCESSFULLY_EXPORTED_RULES(exportCount), From 8c1f57111b15b6fb050fbee7135441aa83632391 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 11:27:43 -0400 Subject: [PATCH 30/37] review with angela --- .../delete_timeline_modal.tsx | 20 ++--- .../delete_timeline_modal/index.tsx | 8 +- .../open_timeline/edit_timeline_actions.tsx | 59 +++++++++---- .../edit_timeline_batch_actions.tsx | 86 +++++++++---------- .../open_timeline/export_timeline/index.tsx | 84 +++++++----------- .../open_timeline/open_timeline.tsx | 15 ++-- .../public/components/open_timeline/types.ts | 8 -- .../detection_engine/routes/rules/utils.ts | 2 +- 8 files changed, 129 insertions(+), 153 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index a32a4df8ce8a6..7d4d5220bca73 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -56,28 +56,18 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel DeleteTimelineModal.displayName = 'DeleteTimelineModal'; -export const useDeleteTimeline = ({ - setActionTimeline, -}: { - setActionTimeline: SetActionTimeline; -}) => { +export const useDeleteTimelineModal = () => { const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); const onCloseDeleteTimelineModal = useCallback(() => { setIsDeleteTimelineModalOpen(false); - setActionTimeline(undefined); - }, [setIsDeleteTimelineModalOpen]); + }, []); - const onOpenDeleteTimelineModal = useCallback( - (selectedActionItem?: OpenTimelineResult) => { - setIsDeleteTimelineModalOpen(true); - setActionTimeline(selectedActionItem); - }, - [setIsDeleteTimelineModalOpen, setActionTimeline] - ); + const onOpenDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(true); + }, []); return { isDeleteTimelineModalOpen, - setIsDeleteTimelineModalOpen, onCloseDeleteTimelineModal, onOpenDeleteTimelineModal, }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index 5631e22c1bdc9..ad82eb574db6a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -20,7 +20,7 @@ interface Props { deleteTimelines: DeleteTimelines; onComplete?: () => void; isModalOpen: boolean; - savedObjectIds?: string[] | null | undefined; + savedObjectIds: string[]; title: string | JSX.Element | null; } /** @@ -34,10 +34,12 @@ export const DeleteTimelineModalOverlay = React.memo( } }, [onComplete]); const onDelete = useCallback(() => { - if (deleteTimelines != null && savedObjectIds != null) { + if (savedObjectIds != null) { deleteTimelines(savedObjectIds); } - if (onComplete != null) onComplete(); + if (onComplete != null) { + onComplete(); + } }, [deleteTimelines, savedObjectIds, onComplete]); return ( <> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx index 6cb1c25d90fd6..112e73a47ce7d 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_actions.tsx @@ -5,31 +5,54 @@ */ import { useState, useCallback } from 'react'; -import { useExportTimeline, ExportTimeline } from './export_timeline/.'; -import { OpenTimelineResult, DeleteTimeline } from './types'; -import { useDeleteTimeline } from './delete_timeline_modal/delete_timeline_modal'; +import { OpenTimelineResult } from './types'; -export const useEditTimelineActions = (selectedItems?: OpenTimelineResult[] | undefined) => { - const [actionItem, setActionTimeline] = useState(undefined); +export const useEditTimelineActions = () => { + const [actionItem, setActionTimeline] = useState(null); + const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); + const [isEnableDownloader, setIsEnableDownloader] = useState(false); - const deleteTimeline: DeleteTimeline = useDeleteTimeline({ - setActionTimeline, - }); - const exportTimeline: ExportTimeline = useExportTimeline({ - selectedItems: actionItem != null ? [actionItem] : selectedItems, - setActionTimeline, - }); + // Handle Delete Modal + const onCloseDeleteTimelineModal = useCallback(() => { + setIsDeleteTimelineModalOpen(false); + setActionTimeline(null); + }, [actionItem]); + const onOpenDeleteTimelineModal = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsDeleteTimelineModalOpen(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + // Handle Downloader Modal + const enableExportTimelineDownloader = useCallback((selectedActionItem?: OpenTimelineResult) => { + setIsEnableDownloader(true); + if (selectedActionItem != null) { + setActionTimeline(selectedActionItem); + } + }, []); + + const disableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); + + // On Compete every tasks const onCompleteEditTimelineAction = useCallback(() => { - deleteTimeline.onCloseDeleteTimelineModal(); - exportTimeline.disableExportTimelineDownloader(); - }, [deleteTimeline.onCloseDeleteTimelineModal, exportTimeline.disableExportTimelineDownloader]); + setIsDeleteTimelineModalOpen(false); + setIsEnableDownloader(false); + setActionTimeline(null); + }, []); return { actionItem, onCompleteEditTimelineAction, - setActionTimeline, - ...deleteTimeline, - ...exportTimeline, + isDeleteTimelineModalOpen, + onCloseDeleteTimelineModal, + onOpenDeleteTimelineModal, + isEnableDownloader, + enableExportTimelineDownloader, + disableExportTimelineDownloader, }; }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index a239e8e158768..4de26b0e69eea 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -5,36 +5,38 @@ */ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; -import React, { useCallback, Dispatch } from 'react'; -import { TimelineDownloader } from './export_timeline/export_timeline'; -import { DeleteTimelineModalOverlay } from './delete_timeline_modal'; +import React, { useCallback, useMemo } from 'react'; import * as i18n from './translations'; -import { DeleteTimeline, DeleteTimelines, OpenTimelineResult } from './types'; -import { ExportTimeline } from './export_timeline'; +import { DeleteTimelines, OpenTimelineResult } from './types'; +import { EditOneTimelineActions } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; -import { ActionToaster } from '../toasters'; -import { exportSelectedTimeline } from '../../containers/timeline/all/api'; + +const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { + const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; + return array.reduce( + (acc, item) => (item.savedObjectId != null ? [...acc, item.savedObjectId] : [...acc]), + [] as string[] + ); +}; export const useEditTimelinBatchActions = ({ deleteTimelines, - dispatchToaster, selectedItems, tableRef, }: { - deleteTimelines: DeleteTimelines | undefined; - dispatchToaster: Dispatch; - selectedItems: OpenTimelineResult[] | undefined; + deleteTimelines?: DeleteTimelines; + selectedItems?: OpenTimelineResult[]; tableRef: React.MutableRefObject | undefined>; }) => { const { enableExportTimelineDownloader, disableExportTimelineDownloader, - exportedIds, isEnableDownloader, isDeleteTimelineModalOpen, onOpenDeleteTimelineModal, onCloseDeleteTimelineModal, - }: DeleteTimeline & ExportTimeline = useEditTimelineActions(selectedItems); + } = useEditTimelineActions(); + const onCompleteBatchActions = useCallback( (closePopover?: () => void) => { if (closePopover != null) closePopover(); @@ -44,44 +46,43 @@ export const useEditTimelinBatchActions = ({ disableExportTimelineDownloader(); onCloseDeleteTimelineModal(); }, - [tableRef.current] + [disableExportTimelineDownloader, onCloseDeleteTimelineModal, tableRef.current] ); + const selectedIds = useMemo(() => getExportedIds(selectedItems ?? []), [selectedItems]); + + const handleEnableExportTimelineDownloader = useCallback(() => enableExportTimelineDownloader(), [ + enableExportTimelineDownloader, + ]); + + const handleOnOpenDeleteTimelineModal = useCallback(() => onOpenDeleteTimelineModal(), [ + onOpenDeleteTimelineModal, + ]); + const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { return ( <> - - {deleteTimelines != null && ( - (item.savedObjectId != null ? [...acc, item.savedObjectId] : acc), - [] as string[] - )} - title={ - selectedItems?.length !== 1 - ? i18n.SELECTED_TIMELINES(selectedItems?.length ?? 0) - : `"${selectedItems[0]?.title}"` - } - /> - )} + { - enableExportTimelineDownloader(); - }} + onClick={handleEnableExportTimelineDownloader} > {i18n.EXPORT_SELECTED} , @@ -89,9 +90,7 @@ export const useEditTimelinBatchActions = ({ disabled={selectedItems == null || selectedItems.length === 0} icon="trash" key="DeleteItemKey" - onClick={() => { - onOpenDeleteTimelineModal(); - }} + onClick={handleOnOpenDeleteTimelineModal} > {i18n.DELETE_SELECTED} , @@ -102,16 +101,13 @@ export const useEditTimelinBatchActions = ({ }, [ deleteTimelines, - dispatchToaster, - disableExportTimelineDownloader, - exportedIds, isEnableDownloader, isDeleteTimelineModalOpen, + selectedIds, selectedItems, - onCloseDeleteTimelineModal, + handleEnableExportTimelineDownloader, + handleOnOpenDeleteTimelineModal, onCompleteBatchActions, - enableExportTimelineDownloader, - onOpenDeleteTimelineModal, ] ); return { onCompleteBatchActions, getBatchItemsPopoverContent }; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index c0c98154faaa2..6e425935e557e 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -15,87 +15,61 @@ import { exportSelectedTimeline } from '../../../containers/timeline/all/api'; export interface ExportTimeline { disableExportTimelineDownloader: () => void; enableExportTimelineDownloader: () => void; - exportedIds: string[] | undefined; + // exportedIds: string[] | undefined; isEnableDownloader: boolean; - setIsEnableDownloader: Dispatch>; + // setIsEnableDownloader: Dispatch>; } -export const useExportTimeline = ({ - selectedItems, - setActionTimeline, -}: { - selectedItems?: OpenTimelineResult | OpenTimelineResult[]; - setActionTimeline: Dispatch>; -}): ExportTimeline => { +export const useExportTimeline = (): ExportTimeline => { const [isEnableDownloader, setIsEnableDownloader] = useState(false); - const getExportedIds = useCallback( - (selectedTimelines: OpenTimelineResult | OpenTimelineResult[]) => { - const array = Array.isArray(selectedTimelines) ? selectedTimelines : [selectedTimelines]; - return array.reduce( - (acc, item) => (item.savedObjectId ? [...acc, item.savedObjectId] : [...acc]), - [] as string[] - ); - }, - [selectedItems] - ); - - const enableExportTimelineDownloader = useCallback( - (selectedActionItem?: OpenTimelineResult) => { - setIsEnableDownloader(true); - setActionTimeline(selectedActionItem); - }, - [setIsEnableDownloader, setActionTimeline] - ); + const enableExportTimelineDownloader = useCallback(() => { + setIsEnableDownloader(true); + }, []); const disableExportTimelineDownloader = useCallback(() => { setIsEnableDownloader(false); - }, [setIsEnableDownloader]); + }, []); return { disableExportTimelineDownloader, enableExportTimelineDownloader, - exportedIds: selectedItems != null ? getExportedIds(selectedItems) : undefined, isEnableDownloader, - setIsEnableDownloader, }; }; const EditTimelineActionsComponent: React.FC<{ - actionItem: OpenTimelineResult | undefined; deleteTimelines: DeleteTimelines | undefined; - exportedIds: string[] | undefined; - getExportedData: ExportSelectedData; + ids: string[]; isEnableDownloader: boolean; isDeleteTimelineModalOpen: boolean; onComplete: () => void; + title: string; }> = ({ - actionItem, deleteTimelines, - exportedIds, - getExportedData, + ids, isEnableDownloader, isDeleteTimelineModalOpen, onComplete, -}) => - actionItem ? ( - <> - ( + <> + + {deleteTimelines != null && ( + - {deleteTimelines != null && actionItem.savedObjectId && ( - - )} - - ) : null; + )} + +); -export const EditTimelineActions = React.memo(EditTimelineActionsComponent); +export const EditOneTimelineActions = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 9fee1bee15841..6d81eb92d15eb 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -14,7 +14,6 @@ import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; import * as i18n from './translations'; -import { useStateToaster } from '../toasters'; import { EditTimelineActions } from './export_timeline/.'; import { UtilityBarGroup, @@ -25,7 +24,7 @@ import { } from '../utility_bar'; import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; -import { exportSelectedTimeline } from '../../containers/timeline/all/api'; + export const OpenTimeline = React.memo( ({ deleteTimelines, @@ -53,12 +52,10 @@ export const OpenTimeline = React.memo( totalSearchResultsCount, }) => { const tableRef = useRef>(); - const [, dispatchToaster] = useStateToaster(); const { actionItem, enableExportTimelineDownloader, - exportedIds, isEnableDownloader, isDeleteTimelineModalOpen, onOpenDeleteTimelineModal, @@ -67,7 +64,6 @@ export const OpenTimeline = React.memo( const { getBatchItemsPopoverContent } = useEditTimelinBatchActions({ deleteTimelines, - dispatchToaster, selectedItems, tableRef, }); @@ -90,16 +86,19 @@ export const OpenTimeline = React.memo( [totalSearchResultsCount, query] ); + const actionItemId = useMemo(() => (actionItem != null ? [actionItem.savedObjectId] : []), [ + actionItem, + ]); + return ( <> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts index 9e3ad10ebf806..b466ea32799d9 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/types.ts @@ -79,16 +79,8 @@ export type OnOpenTimeline = ({ timelineId: string; }) => void; -export interface DeleteTimeline { - isDeleteTimelineModalOpen: boolean; - setIsDeleteTimelineModalOpen: Dispatch>; - onCloseDeleteTimelineModal: () => void; - onOpenDeleteTimelineModal: (selectedActionItem?: OpenTimelineResult | undefined) => void; -} export type OnOpenDeleteTimelineModal = (selectedItem: OpenTimelineResult) => void; -export type OnCloseDeleteTimelineModal = () => void; export type SetActionTimeline = Dispatch>; -export type DisableExportTimelineDownloader = () => void; export type EnableExportTimelineDownloader = (selectedItem: OpenTimelineResult) => void; /** Invoked when the user presses enters to submit the text in the search input */ export type OnQueryChange = (query: EuiSearchBarQuery) => void; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index 38245245c96c2..3896892b77ecd 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -142,7 +142,7 @@ export const transformAlertToRule = ( }); }; -export const transformDataToNdjson = (data: Array>): string => { +export const transformDataToNdjson = (data: unknown[]): string => { if (data.length !== 0) { const dataString = data.map(rule => JSON.stringify(rule)).join('\n'); return `${dataString}\n`; From 0d4307c0048c037084ffe18d7be4603e81a16417 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Wed, 18 Mar 2020 12:05:47 -0400 Subject: [PATCH 31/37] review utils --- .../siem/server/lib/timeline/routes/utils.ts | 131 ++++++++++-------- 1 file changed, 70 insertions(+), 61 deletions(-) diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index ef6a44e85c3b1..aae17a6b22f39 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import { set as _set } from 'lodash/fp'; -import { SavedObjectsFindOptions } from '../../../../../../../../src/core/server'; +import { + SavedObjectsFindOptions, + SavedObjectsFindResponse, +} from '../../../../../../../../src/core/server'; import { ExportTimelineSavedObjectsClient, @@ -27,41 +30,47 @@ import { transformDataToNdjson } from '../../detection_engine/routes/rules/utils import { NoteSavedObject } from '../../note/types'; import { PinnedEventSavedObject } from '../../pinned_event/types'; -const getAllSavedNote = async ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - options: SavedObjectsFindOptions -): Promise => { - const savedObjects = await savedObjectsClient.find(options); - return savedObjects != null - ? savedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)) +const getAllSavedPinnedEvents = ( + pinnedEventsSavedObjects: SavedObjectsFindResponse +): PinnedEventSavedObject[] => { + return pinnedEventsSavedObjects != null + ? pinnedEventsSavedObjects.saved_objects.map(savedObject => + convertSavedObjectToSavedPinnedEvent(savedObject) + ) : []; }; -const getNotesByTimelineId = ( +const getPinnedEventsByTimelineId = ( savedObjectsClient: ExportTimelineSavedObjectsClient, timelineId: string -): Promise => { +): Promise> => { const options: SavedObjectsFindOptions = { - type: noteSavedObjectType, + type: pinnedEventSavedObjectType, search: timelineId, searchFields: ['timelineId'], }; + return savedObjectsClient.find(options); +}; - return getAllSavedNote(savedObjectsClient, options); +const getAllSavedNote = ( + noteSavedObjects: SavedObjectsFindResponse +): NoteSavedObject[] => { + return noteSavedObjects != null + ? noteSavedObjects.saved_objects.map(savedObject => convertSavedObjectToSavedNote(savedObject)) + : []; }; -const getNotesAndPinnedEventsByTimelineId = async ( +const getNotesByTimelineId = ( savedObjectsClient: ExportTimelineSavedObjectsClient, timelineId: string -): Promise => { - return { - [timelineId]: { - notes: await Promise.resolve(getNotesByTimelineId(savedObjectsClient, timelineId)), - pinnedEvents: await Promise.resolve( - getPinnedEventsByTimelineId(savedObjectsClient, timelineId) - ), - }, +): Promise> => { + const options: SavedObjectsFindOptions = { + type: noteSavedObjectType, + search: timelineId, + searchFields: ['timelineId'], }; + + return savedObjectsClient.find(options); }; const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): ExportedNotes => { @@ -100,31 +109,6 @@ const getExportedNotedandPinnedEvents = ( }; }; -const getAllSavedPinnedEvents = async ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - options: SavedObjectsFindOptions -): Promise => { - const savedObjects = await savedObjectsClient.find(options); - - return savedObjects != null - ? savedObjects.saved_objects.map(savedObject => - convertSavedObjectToSavedPinnedEvent(savedObject) - ) - : []; -}; - -const getPinnedEventsByTimelineId = ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - timelineId: string -): Promise => { - const options: SavedObjectsFindOptions = { - type: pinnedEventSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], - }; - return getAllSavedPinnedEvents(savedObjectsClient, options); -}; - const getPinnedEventsIdsByTimelineId = ( currentPinnedEvents: PinnedEventSavedObject[] ): string[] => { @@ -162,25 +146,50 @@ const getTimelinesFromObjects = async ( savedObjectsClient, request.body.objects ); + // To Do for feature freeze + // if (timelines.length !== request.body.objects.length) { + // //figure out which is missing to tell user + // } + + const [notes, pinnedEventIds] = await Promise.all([ + Promise.all( + request.body.objects.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) + ), + Promise.all( + request.body.objects.map(timelineId => + getPinnedEventsByTimelineId(savedObjectsClient, timelineId) + ) + ), + ]); - const notesAndPinnedEvents: NotesAndPinnedEventsByTimelineId[] = await Promise.all( - request.body.objects.reduce( - (acc, timelineId) => - timelineId != null - ? [...acc, getNotesAndPinnedEventsByTimelineId(savedObjectsClient, timelineId)] - : [...acc], - [] as Array> - ) + const myNotes = notes.reduce( + (acc, note) => [...acc, ...getAllSavedNote(note)], + [] ); - return ( - timelines?.map(timeline => { - return { - ...timeline, - ...getExportedNotedandPinnedEvents(notesAndPinnedEvents, timeline.savedObjectId), - }; - }) ?? [] + const myPinnedEventIds = pinnedEventIds.reduce( + (acc, pinnedEventId) => [...acc, ...getAllSavedPinnedEvents(pinnedEventId)], + [] ); + + const myResponse = request.body.objects.reduce((acc, timelineId) => { + const myTimeline = timelines.find(t => t.savedObjectId === timelineId); + if (myTimeline != null) { + const timelineNotes = myNotes.filter(n => n.timelineId === timelineId); + const timelinePinnedEventIds = myPinnedEventIds.filter(p => p.timelineId === timelineId); + return [ + ...acc, + { + ...myTimeline, + ...getGlobalEventNotesByTimelineId(timelineNotes), + pinnedEventIds: getPinnedEventsIdsByTimelineId(timelinePinnedEventIds), + }, + ]; + } + return acc; + }, []); + + return myResponse ?? []; }; export const getExportTimelineByObjectIds = async ({ From b914860ce77158469d39d81a02ee076f6562ba22 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 18 Mar 2020 20:42:55 +0000 Subject: [PATCH 32/37] fix for code review --- .../components/generic_downloader/index.tsx | 19 +- .../delete_timeline_modal.tsx | 1 - .../edit_timeline_batch_actions.tsx | 4 +- .../export_timeline/export_timeline.tsx | 42 ++-- .../export_timeline/index.test.tsx | 36 +--- .../open_timeline/export_timeline/index.tsx | 10 +- .../open_timeline/open_timeline.tsx | 28 +-- .../timelines_table/actions_columns.test.tsx | 189 ++++-------------- .../timelines_table/common_columns.test.tsx | 78 +++----- .../timelines_table/extended_columns.test.tsx | 30 +-- .../icon_header_columns.test.tsx | 45 ++--- .../timelines_table/index.test.tsx | 55 ++--- .../open_timeline/timelines_table/mocks.ts | 32 +++ .../siem/server/lib/timeline/routes/utils.ts | 15 -- 14 files changed, 195 insertions(+), 389 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 382bcf47abeeb..9ef42bb1d7cd5 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { isFunction, isNil } from 'lodash/fp'; +import { isFunction } from 'lodash/fp'; import * as i18n from './translations'; import { ExportDocumentsProps } from '../../containers/detection_engine/rules'; @@ -54,19 +54,14 @@ export const GenericDownloaderComponent = ({ const abortCtrl = new AbortController(); const exportData = async () => { - if ( - anchorRef && - anchorRef.current && - ((ids != null && ids.length > 0) || (ids != null && ids.length > 0)) - ) { + if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { let exportResponse; try { - if (!isNil(exportSelectedData)) { - exportResponse = await exportSelectedData({ - ids, - signal: abortCtrl.signal, - }); - } + exportResponse = await exportSelectedData({ + ids, + signal: abortCtrl.signal, + }); + if (isSubscribed) { // this is for supporting IE if (isFunction(window.navigator.msSaveOrOpenBlob)) { diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 7d4d5220bca73..674cb96b5e504 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -9,7 +9,6 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback, useState } from 'react'; import * as i18n from '../translations'; -import { OpenTimelineResult, SetActionTimeline } from '../types'; interface Props { title?: string | JSX.Element | null; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index 4de26b0e69eea..d5fb95163bc6f 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -8,7 +8,7 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic import React, { useCallback, useMemo } from 'react'; import * as i18n from './translations'; import { DeleteTimelines, OpenTimelineResult } from './types'; -import { EditOneTimelineActions } from './export_timeline'; +import { EditTimelineActions } from './export_timeline'; import { useEditTimelineActions } from './edit_timeline_actions'; const getExportedIds = (selectedTimelines: OpenTimelineResult[]) => { @@ -63,7 +63,7 @@ export const useEditTimelinBatchActions = ({ (closePopover: () => void) => { return ( <> - void; }> = ({ onComplete, isEnableDownloader, exportedIds, getExportedData }) => { const [, dispatchToaster] = useStateToaster(); + const onExportSuccess = useCallback( + exportCount => { + if (onComplete != null) { + onComplete(); + } + dispatchToaster({ + type: 'addToaster', + toast: { + id: uuid.v4(), + title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), + color: 'success', + iconType: 'check', + }, + }); + }, + [dispatchToaster, onComplete] + ); + const onExportFailure = useCallback(() => { + if (onComplete != null) { + onComplete(); + } + }, [onComplete]); + return ( <> {exportedIds != null && isEnableDownloader && ( @@ -25,21 +48,8 @@ const ExportTimeline: React.FC<{ exportSelectedData={getExportedData} filename={`${i18n.EXPORT_FILENAME}.ndjson`} ids={exportedIds} - onExportSuccess={exportCount => { - if (onComplete != null) onComplete(); - dispatchToaster({ - type: 'addToaster', - toast: { - id: uuid.v4(), - title: i18n.SUCCESSFULLY_EXPORTED_TIMELINES(exportCount), - color: 'success', - iconType: 'check', - }, - }); - }} - onExportFailure={() => { - if (onComplete != null) onComplete(); - }} + onExportSuccess={onExportSuccess} + onExportFailure={onExportFailure} /> )} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx index 7568568ac1f1c..674cd6dad5f76 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.test.tsx @@ -5,19 +5,14 @@ */ import React from 'react'; -import { mockSelectedTimeline } from './mocks'; import { mount } from 'enzyme'; import { useExportTimeline, ExportTimeline } from '.'; -import { get } from 'lodash/fp'; describe('useExportTimeline', () => { describe('call with selected timelines', () => { let exportTimelineRes: ExportTimeline; const TestHook = () => { - exportTimelineRes = useExportTimeline({ - selectedItems: mockSelectedTimeline, - setActionTimeline: jest.fn(), - }); + exportTimelineRes = useExportTimeline(); return
; }; @@ -29,33 +24,12 @@ describe('useExportTimeline', () => { expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); }); - test('exportedIds should include timelineId', () => { - expect(get('exportedIds[0]', exportTimelineRes)).toEqual( - mockSelectedTimeline[0].savedObjectId - ); - }); - }); - - describe('call without selected timelines', () => { - let exportTimelineRes: ExportTimeline; - const TestHook = () => { - exportTimelineRes = useExportTimeline({ - selectedItems: undefined, - setActionTimeline: jest.fn(), - }); - return
; - }; - - beforeAll(() => { - mount(); - }); - - test('should contain exportedIds', () => { - expect(exportTimelineRes?.hasOwnProperty('exportedIds')).toBeTruthy(); + test('Should include disableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); }); - test('should have no exportedIds', () => { - expect(exportTimelineRes.exportedIds).toBeUndefined(); + test('Should include enableExportTimelineDownloader in return value', () => { + expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx index 6e425935e557e..946c4b3a612dd 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/export_timeline/index.tsx @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback, Dispatch, SetStateAction } from 'react'; -import { OpenTimelineResult, DeleteTimelines } from '../types'; -import { ExportSelectedData } from '../../generic_downloader'; +import React, { useState, useCallback } from 'react'; +import { DeleteTimelines } from '../types'; import { TimelineDownloader } from './export_timeline'; import { DeleteTimelineModalOverlay } from '../delete_timeline_modal'; @@ -15,9 +14,7 @@ import { exportSelectedTimeline } from '../../../containers/timeline/all/api'; export interface ExportTimeline { disableExportTimelineDownloader: () => void; enableExportTimelineDownloader: () => void; - // exportedIds: string[] | undefined; isEnableDownloader: boolean; - // setIsEnableDownloader: Dispatch>; } export const useExportTimeline = (): ExportTimeline => { @@ -72,4 +69,5 @@ const EditTimelineActionsComponent: React.FC<{ ); -export const EditOneTimelineActions = React.memo(EditTimelineActionsComponent); +export const EditTimelineActions = React.memo(EditTimelineActionsComponent); +export const EditOneTimelineAction = React.memo(EditTimelineActionsComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 6d81eb92d15eb..8de5db3f5ba5c 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -14,7 +14,6 @@ import { TimelinesTable } from './timelines_table'; import { TitleRow } from './title_row'; import * as i18n from './translations'; -import { EditTimelineActions } from './export_timeline/.'; import { UtilityBarGroup, UtilityBarText, @@ -24,6 +23,7 @@ import { } from '../utility_bar'; import { useEditTimelinBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; +import { EditOneTimelineAction } from './export_timeline'; export const OpenTimeline = React.memo( ({ @@ -86,19 +86,27 @@ export const OpenTimeline = React.memo( [totalSearchResultsCount, query] ); - const actionItemId = useMemo(() => (actionItem != null ? [actionItem.savedObjectId] : []), [ - actionItem, - ]); + const actionItemId = useMemo( + () => + actionItem != null && actionItem.savedObjectId != null ? [actionItem.savedObjectId] : [], + [actionItem] + ); + + const onRefreshBtnClick = useCallback(() => { + if (typeof refetch === 'function') refetch(); + }, [refetch]); return ( <> - @@ -137,13 +145,7 @@ export const OpenTimeline = React.memo( > {i18n.BATCH_ACTIONS} - { - if (typeof refetch === 'function') refetch(); - }, [refetch])} - > + {i18n.REFRESH} diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx index 45b8c8f6e0ed2..ca82e30798d82 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/actions_columns.test.tsx @@ -11,10 +11,10 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -29,26 +29,13 @@ describe('#getActionsColumns', () => { }); test('it renders the delete timeline (trash icon) when actionTimelineToShow is including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -56,26 +43,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow is NOT including the action delete', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -83,26 +57,13 @@ describe('#getActionsColumns', () => { }); test('it renders the duplicate icon timeline when actionTimelineToShow is including the action duplicate', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: ['duplicate'], + }; const wrapper = mountWithIntl( - + ); @@ -110,26 +71,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the duplicate timeline when actionTimelineToShow is NOT including the action duplicate)', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + actionTimelineToShow: [], + }; const wrapper = mountWithIntl( - + ); @@ -137,25 +85,13 @@ describe('#getActionsColumns', () => { }); test('it does NOT render the delete timeline (trash icon) when deleteTimelines is not provided', () => { + const testProps: TimelinesTableProps = { + ...omit('deleteTimelines', getMockTimelinesTableProps(mockResults)), + actionTimelineToShow: ['delete'], + }; const wrapper = mountWithIntl( - + ); @@ -167,56 +103,29 @@ describe('#getActionsColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(missingSavedObjectId), + }; const wrapper = mountWithIntl( - + + + ); const props = wrapper .find('[data-test-subj="open-duplicate"]') .first() .props() as EuiButtonIconProps; - expect(props.isDisabled).toBe(true); }); test('it renders an enabled the open duplicate button if the timeline has have a saved object id', () => { + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + }; const wrapper = mountWithIntl( - + ); @@ -230,27 +139,13 @@ describe('#getActionsColumns', () => { test('it invokes onOpenTimeline with the expected params when the button is clicked', () => { const onOpenTimeline = jest.fn(); - + const testProps: TimelinesTableProps = { + ...getMockTimelinesTableProps(mockResults), + onOpenTimeline, + }; const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx index bb8f0dec09627..093e4a5bab100 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/common_columns.test.tsx @@ -11,7 +11,6 @@ import React from 'react'; import { ThemeProvider } from 'styled-components'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { OpenTimelineResult } from '../types'; import { mockTimelineResults } from '../../../mock/timeline_results'; @@ -19,33 +18,14 @@ import { NotePreviews } from '../note_previews'; import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); describe('#getCommonColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ - actionTimelineToShow: ['delete', 'duplicate', 'selectable'], - deleteTimelines: jest.fn(), - defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - enableExportTimelineDownloader: jest.fn(), - itemIdToExpandedNotesRowMap: {}, - loading: false, - onOpenDeleteTimelineModal: jest.fn(), - onOpenTimeline: jest.fn(), - onSelectionChange: jest.fn(), - onTableChange: jest.fn(), - onToggleShowNotes: jest.fn(), - pageIndex: 0, - pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - searchResults: mockOpenTimelineResults, - showExtendedColumns: true, - sortDirection: DEFAULT_SORT_DIRECTION, - sortField: DEFAULT_SORT_FIELD, - totalSearchResultsCount: mockOpenTimelineResults.length, - }); + beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); @@ -55,7 +35,7 @@ describe('#getCommonColumns', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(hasNotes), + ...getMockTimelinesTableProps(hasNotes), }; const wrapper = mountWithIntl( @@ -70,7 +50,7 @@ describe('#getCommonColumns', () => { const missingNotes: OpenTimelineResult[] = [omit('notes', { ...mockResults[0] })]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingNotes), + ...getMockTimelinesTableProps(missingNotes), }; const wrapper = mountWithIntl( @@ -85,7 +65,7 @@ describe('#getCommonColumns', () => { const nullNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: null }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(nullNotes), + ...getMockTimelinesTableProps(nullNotes), }; const wrapper = mountWithIntl( @@ -100,7 +80,7 @@ describe('#getCommonColumns', () => { const emptylNotes: OpenTimelineResult[] = [{ ...mockResults[0], notes: [] }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(emptylNotes), + ...getMockTimelinesTableProps(emptylNotes), }; const wrapper = mountWithIntl( @@ -116,7 +96,7 @@ describe('#getCommonColumns', () => { omit('savedObjectId', { ...mockResults[0] }), ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingSavedObjectId), + ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( @@ -131,7 +111,7 @@ describe('#getCommonColumns', () => { const nullSavedObjectId: OpenTimelineResult[] = [{ ...mockResults[0], savedObjectId: null }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(nullSavedObjectId), + ...getMockTimelinesTableProps(nullSavedObjectId), }; const wrapper = mountWithIntl( @@ -146,7 +126,7 @@ describe('#getCommonColumns', () => { const hasNotes: OpenTimelineResult[] = [{ ...mockResults[0] }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(hasNotes), + ...getMockTimelinesTableProps(hasNotes), }; const wrapper = mountWithIntl( @@ -170,7 +150,7 @@ describe('#getCommonColumns', () => { }; const testProps: TimelinesTableProps = { - ...getDefaultProps(hasNotes), + ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, }; const wrapper = mountWithIntl( @@ -197,7 +177,7 @@ describe('#getCommonColumns', () => { }; const testProps: TimelinesTableProps = { - ...getDefaultProps(hasNotes), + ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, onToggleShowNotes, }; @@ -229,7 +209,7 @@ describe('#getCommonColumns', () => { }; const testProps: TimelinesTableProps = { - ...getDefaultProps(hasNotes), + ...getMockTimelinesTableProps(hasNotes), itemIdToExpandedNotesRowMap, onToggleShowNotes, }; @@ -253,7 +233,7 @@ describe('#getCommonColumns', () => { describe('Timeline Name column', () => { test('it renders the expected column name', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( @@ -271,7 +251,7 @@ describe('#getCommonColumns', () => { test('it renders the title when the timeline has a title and a saved object id', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( @@ -293,7 +273,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingSavedObjectId), + ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( @@ -313,7 +293,7 @@ describe('#getCommonColumns', () => { const missingTitle: OpenTimelineResult[] = [omit('title', { ...mockResults[0] })]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingTitle), + ...getMockTimelinesTableProps(missingTitle), }; const wrapper = mountWithIntl( @@ -335,7 +315,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(withMissingSavedObjectIdAndTitle), + ...getMockTimelinesTableProps(withMissingSavedObjectIdAndTitle), }; const wrapper = mountWithIntl( @@ -357,7 +337,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(withJustWhitespaceTitle), + ...getMockTimelinesTableProps(withJustWhitespaceTitle), }; const wrapper = mountWithIntl( @@ -379,7 +359,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(withMissingSavedObjectId), + ...getMockTimelinesTableProps(withMissingSavedObjectId), }; const wrapper = mountWithIntl( @@ -398,7 +378,7 @@ describe('#getCommonColumns', () => { test('it renders a hyperlink when the timeline has a saved object id', () => { const wrapper = mountWithIntl( - + ); @@ -416,7 +396,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingSavedObjectId), + ...getMockTimelinesTableProps(missingSavedObjectId), }; const wrapper = mountWithIntl( @@ -436,7 +416,7 @@ describe('#getCommonColumns', () => { const onOpenTimeline = jest.fn(); const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), onOpenTimeline, }; const wrapper = mountWithIntl( @@ -461,7 +441,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -476,7 +456,7 @@ describe('#getCommonColumns', () => { test('it renders the description when the timeline has a description', () => { const wrapper = mountWithIntl( - + ); @@ -493,7 +473,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( @@ -510,7 +490,7 @@ describe('#getCommonColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(justWhitespaceDescription), + ...getMockTimelinesTableProps(justWhitespaceDescription), }; const wrapper = mountWithIntl( @@ -530,7 +510,7 @@ describe('#getCommonColumns', () => { test('it renders the expected column name', () => { const wrapper = mountWithIntl( - + ); @@ -545,7 +525,7 @@ describe('#getCommonColumns', () => { test('it renders the last modified (updated) date when the timeline has an updated property', () => { const wrapper = mountWithIntl( - + ); @@ -563,7 +543,7 @@ describe('#getCommonColumns', () => { const wrapper = mountWithIntl( - + ); expect( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx index 91a260b5ba5cc..3960d08765126 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/extended_columns.test.tsx @@ -10,7 +10,6 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { getEmptyValue } from '../../empty_value'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; @@ -18,7 +17,7 @@ import { OpenTimelineResult } from '../types'; import { TimelinesTable, TimelinesTableProps } from '.'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); @@ -26,27 +25,6 @@ describe('#getExtendedColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ - actionTimelineToShow: ['delete', 'duplicate', 'selectable'], - deleteTimelines: jest.fn(), - defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - enableExportTimelineDownloader: jest.fn(), - itemIdToExpandedNotesRowMap: {}, - loading: false, - onOpenDeleteTimelineModal: jest.fn(), - onOpenTimeline: jest.fn(), - onSelectionChange: jest.fn(), - onTableChange: jest.fn(), - onToggleShowNotes: jest.fn(), - pageIndex: 0, - pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - searchResults: mockOpenTimelineResults, - showExtendedColumns: true, - sortDirection: DEFAULT_SORT_DIRECTION, - sortField: DEFAULT_SORT_FIELD, - totalSearchResultsCount: mockOpenTimelineResults.length, - }); - beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); @@ -54,7 +32,7 @@ describe('#getExtendedColumns', () => { describe('Modified By column', () => { test('it renders the expected column name', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( @@ -72,7 +50,7 @@ describe('#getExtendedColumns', () => { test('it renders the username when the timeline has an updatedBy property', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), }; const wrapper = mountWithIntl( @@ -91,7 +69,7 @@ describe('#getExtendedColumns', () => { test('it renders a placeholder when the timeline is missing the updatedBy property', () => { const missingUpdatedBy: OpenTimelineResult[] = [omit('updatedBy', { ...mockResults[0] })]; const testProps: TimelinesTableProps = { - ...getDefaultProps(missingUpdatedBy), + ...getMockTimelinesTableProps(missingUpdatedBy), }; const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx index 78deded55e687..658dd96faa986 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/icon_header_columns.test.tsx @@ -10,37 +10,16 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { TimelinesTable, TimelinesTableProps } from '.'; import { OpenTimelineResult } from '../types'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; - +import { getMockTimelinesTableProps } from './mocks'; jest.mock('../../../lib/kibana'); describe('#getActionsColumns', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ - actionTimelineToShow: ['delete', 'duplicate', 'selectable'], - deleteTimelines: jest.fn(), - defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - enableExportTimelineDownloader: jest.fn(), - itemIdToExpandedNotesRowMap: {}, - loading: false, - onOpenDeleteTimelineModal: jest.fn(), - onOpenTimeline: jest.fn(), - onSelectionChange: jest.fn(), - onTableChange: jest.fn(), - onToggleShowNotes: jest.fn(), - pageIndex: 0, - pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - searchResults: mockOpenTimelineResults, - showExtendedColumns: true, - sortDirection: DEFAULT_SORT_DIRECTION, - sortField: DEFAULT_SORT_FIELD, - totalSearchResultsCount: mockOpenTimelineResults.length, - }); + beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); }); @@ -48,7 +27,7 @@ describe('#getActionsColumns', () => { test('it renders the pinned events header icon', () => { const wrapper = mountWithIntl( - + ); @@ -58,7 +37,7 @@ describe('#getActionsColumns', () => { test('it renders the expected pinned events count', () => { const with6Events = [mockResults[0]]; const testProps: TimelinesTableProps = { - ...getDefaultProps(with6Events), + ...getMockTimelinesTableProps(with6Events), }; const wrapper = mountWithIntl( @@ -72,7 +51,7 @@ describe('#getActionsColumns', () => { test('it renders the notes count header icon', () => { const wrapper = mountWithIntl( - + ); @@ -82,7 +61,7 @@ describe('#getActionsColumns', () => { test('it renders the expected notes count', () => { const with4Notes = [mockResults[0]]; const testProps: TimelinesTableProps = { - ...getDefaultProps(with4Notes), + ...getMockTimelinesTableProps(with4Notes), }; const wrapper = mountWithIntl( @@ -96,7 +75,7 @@ describe('#getActionsColumns', () => { test('it renders the favorites header icon', () => { const wrapper = mountWithIntl( - + ); @@ -106,7 +85,7 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is undefined', () => { const undefinedFavorite: OpenTimelineResult[] = [omit('favorite', { ...mockResults[0] })]; const testProps: TimelinesTableProps = { - ...getDefaultProps(undefinedFavorite), + ...getMockTimelinesTableProps(undefinedFavorite), }; const wrapper = mountWithIntl( @@ -120,7 +99,7 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is null', () => { const nullFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: null }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(nullFavorite), + ...getMockTimelinesTableProps(nullFavorite), }; const wrapper = mountWithIntl( @@ -134,7 +113,7 @@ describe('#getActionsColumns', () => { test('it renders an empty star when favorite is empty', () => { const emptyFavorite: OpenTimelineResult[] = [{ ...mockResults[0], favorite: [] }]; const testProps: TimelinesTableProps = { - ...getDefaultProps(emptyFavorite), + ...getMockTimelinesTableProps(emptyFavorite), }; const wrapper = mountWithIntl( @@ -159,7 +138,7 @@ describe('#getActionsColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(favorite), + ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( @@ -188,7 +167,7 @@ describe('#getActionsColumns', () => { ]; const testProps: TimelinesTableProps = { - ...getDefaultProps(favorite), + ...getMockTimelinesTableProps(favorite), }; const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx index edd06f74f0330..e124f58a0c989 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/index.test.tsx @@ -10,39 +10,18 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import React from 'react'; import { ThemeProvider } from 'styled-components'; -import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; import { mockTimelineResults } from '../../../mock/timeline_results'; import { OpenTimelineResult } from '../types'; import { TimelinesTable, TimelinesTableProps } from '.'; +import { getMockTimelinesTableProps } from './mocks'; import * as i18n from '../translations'; -import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; jest.mock('../../../lib/kibana'); describe('TimelinesTable', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); let mockResults: OpenTimelineResult[]; - const getDefaultProps = (mockOpenTimelineResults: OpenTimelineResult[]): TimelinesTableProps => ({ - actionTimelineToShow: ['delete', 'duplicate', 'selectable'], - deleteTimelines: jest.fn(), - defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - enableExportTimelineDownloader: jest.fn(), - itemIdToExpandedNotesRowMap: {}, - loading: false, - onOpenDeleteTimelineModal: jest.fn(), - onOpenTimeline: jest.fn(), - onSelectionChange: jest.fn(), - onTableChange: jest.fn(), - onToggleShowNotes: jest.fn(), - pageIndex: 0, - pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, - searchResults: mockOpenTimelineResults, - showExtendedColumns: true, - sortDirection: DEFAULT_SORT_DIRECTION, - sortField: DEFAULT_SORT_FIELD, - totalSearchResultsCount: mockOpenTimelineResults.length, - }); beforeEach(() => { mockResults = cloneDeep(mockTimelineResults); @@ -51,7 +30,7 @@ describe('TimelinesTable', () => { test('it renders the select all timelines header checkbox when actionTimelineToShow has the action selectable', () => { const wrapper = mountWithIntl( - + ); @@ -65,7 +44,7 @@ describe('TimelinesTable', () => { test('it does NOT render the select all timelines header checkbox when actionTimelineToShow has not the action selectable', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), actionTimelineToShow: ['delete', 'duplicate'], }; const wrapper = mountWithIntl( @@ -84,7 +63,7 @@ describe('TimelinesTable', () => { test('it renders the Modified By column when showExtendedColumns is true ', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), showExtendedColumns: true, }; const wrapper = mountWithIntl( @@ -103,7 +82,7 @@ describe('TimelinesTable', () => { test('it renders the notes column in the position of the Modified By column when showExtendedColumns is false', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), showExtendedColumns: false, }; const wrapper = mountWithIntl( @@ -125,7 +104,7 @@ describe('TimelinesTable', () => { test('it renders the delete timeline (trash icon) when actionTimelineToShow has the delete action', () => { const wrapper = mountWithIntl( - + ); @@ -139,7 +118,7 @@ describe('TimelinesTable', () => { test('it does NOT render the delete timeline (trash icon) when actionTimelineToShow has NOT the delete action', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), actionTimelineToShow: ['duplicate', 'selectable'], }; const wrapper = mountWithIntl( @@ -159,7 +138,7 @@ describe('TimelinesTable', () => { test('it renders the rows per page selector when showExtendedColumns is true', () => { const wrapper = mountWithIntl( - + ); @@ -173,7 +152,7 @@ describe('TimelinesTable', () => { test('it does NOT render the rows per page selector when showExtendedColumns is false', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), showExtendedColumns: false, }; const wrapper = mountWithIntl( @@ -193,7 +172,7 @@ describe('TimelinesTable', () => { test('it renders the default page size specified by the defaultPageSize prop', () => { const defaultPageSize = 123; const testProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), defaultPageSize, pageSize: defaultPageSize, }; @@ -214,7 +193,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is true ', () => { const wrapper = mountWithIntl( - + ); @@ -228,7 +207,7 @@ describe('TimelinesTable', () => { test('it sorts the Last Modified column in descending order when showExtendedColumns is false ', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), showExtendedColumns: false, }; const wrapper = mountWithIntl( @@ -247,7 +226,7 @@ describe('TimelinesTable', () => { test('it displays the expected message when no search results are found', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), searchResults: [], }; const wrapper = mountWithIntl( @@ -267,7 +246,7 @@ describe('TimelinesTable', () => { test('it invokes onTableChange with the expected parameters when a table header is clicked to sort it', () => { const onTableChange = jest.fn(); const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), onTableChange, }; const wrapper = mountWithIntl( @@ -292,7 +271,7 @@ describe('TimelinesTable', () => { test('it invokes onSelectionChange when a row is selected', () => { const onSelectionChange = jest.fn(); const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), onSelectionChange, }; const wrapper = mountWithIntl( @@ -313,7 +292,7 @@ describe('TimelinesTable', () => { test('it enables the table loading animation when isLoading is true', () => { const testProps: TimelinesTableProps = { - ...getDefaultProps(mockResults), + ...getMockTimelinesTableProps(mockResults), loading: true, }; const wrapper = mountWithIntl( @@ -333,7 +312,7 @@ describe('TimelinesTable', () => { test('it disables the table loading animation when isLoading is false', () => { const wrapper = mountWithIntl( - + ); diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts new file mode 100644 index 0000000000000..519dfc1b66efe --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/timelines_table/mocks.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines/timelines_page'; +import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { OpenTimelineResult } from '../types'; +import { TimelinesTableProps } from '.'; + +export const getMockTimelinesTableProps = ( + mockOpenTimelineResults: OpenTimelineResult[] +): TimelinesTableProps => ({ + actionTimelineToShow: ['delete', 'duplicate', 'selectable'], + deleteTimelines: jest.fn(), + defaultPageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + enableExportTimelineDownloader: jest.fn(), + itemIdToExpandedNotesRowMap: {}, + loading: false, + onOpenDeleteTimelineModal: jest.fn(), + onOpenTimeline: jest.fn(), + onSelectionChange: jest.fn(), + onTableChange: jest.fn(), + onToggleShowNotes: jest.fn(), + pageIndex: 0, + pageSize: DEFAULT_SEARCH_RESULTS_PER_PAGE, + searchResults: mockOpenTimelineResults, + showExtendedColumns: true, + sortDirection: DEFAULT_SORT_DIRECTION, + sortField: DEFAULT_SORT_FIELD, + totalSearchResultsCount: mockOpenTimelineResults.length, +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index aae17a6b22f39..58cb16e74c7ab 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -15,7 +15,6 @@ import { ExportedNotes, TimelineSavedObject, ExportedTimelines, - NotesAndPinnedEventsByTimelineId, } from '../types'; import { timelineSavedObjectType, @@ -95,20 +94,6 @@ const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): Expor ); }; -const getExportedNotedandPinnedEvents = ( - data: NotesAndPinnedEventsByTimelineId[], - timelineId: string -) => { - const currentRecord = data.find(note => Object.keys(note)[0] === timelineId) ?? {}; - const currentNote = currentRecord[timelineId]?.notes ?? []; - const currentPinnedEvents = currentRecord[timelineId]?.pinnedEvents ?? []; - - return { - ...getGlobalEventNotesByTimelineId(currentNote), - pinnedEventIds: getPinnedEventsIdsByTimelineId(currentPinnedEvents), - }; -}; - const getPinnedEventsIdsByTimelineId = ( currentPinnedEvents: PinnedEventSavedObject[] ): string[] => { From 7a9ebbbc54094db4f6b65498ed5d5948848717db Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 18 Mar 2020 21:32:27 +0000 Subject: [PATCH 33/37] fix for review --- .../edit_timeline_batch_actions.tsx | 6 ++++-- .../siem/public/containers/timeline/all/api.ts | 2 +- .../timeline/routes/export_timelines_route.ts | 16 ++++------------ .../routes/schemas/export_timelines_schema.ts | 4 ++-- .../lib/timeline/routes/schemas/schemas.ts | 2 +- 5 files changed, 12 insertions(+), 18 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index d5fb95163bc6f..6a93c21d2cc40 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -6,6 +6,7 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; +import { isEmpty } from 'lodash/fp'; import * as i18n from './translations'; import { DeleteTimelines, OpenTimelineResult } from './types'; import { EditTimelineActions } from './export_timeline'; @@ -61,6 +62,7 @@ export const useEditTimelinBatchActions = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { + const isDisabled = isEmpty(selectedItems); return ( <> , => { - const body = ids.length > 0 ? JSON.stringify({ objects: ids }) : undefined; + const body = ids.length > 0 ? JSON.stringify({ ids }) : undefined; const response = await KibanaServices.get().http.fetch(`${TIMELINE_EXPORT_URL}`, { method: 'POST', body, diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 14bd8a1b419fd..944a886b0edee 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -38,19 +38,9 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co }, }, async (context, request, response) => { - const siemResponse = buildSiemResponse(response); - let savedObjectsClient; - try { - savedObjectsClient = context.core.savedObjects.client; - } catch (err) { - const error = transformError(err); - return siemResponse.error({ - body: error.message, - statusCode: error.statusCode, - }); - } - try { + const siemResponse = buildSiemResponse(response); + const savedObjectsClient = context.core.savedObjects.client; const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { return siemResponse.error({ @@ -73,6 +63,8 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co }); } catch (err) { const error = transformError(err); + const siemResponse = buildSiemResponse(response); + return siemResponse.error({ body: error.message, statusCode: error.statusCode, diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts index ab7cc0175810e..04edbbd7046c9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/export_timelines_schema.ts @@ -7,11 +7,11 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ -import { objects, exclude_export_details, file_name } from './schemas'; +import { ids, exclude_export_details, file_name } from './schemas'; /* eslint-disable @typescript-eslint/camelcase */ export const exportTimelinesSchema = Joi.object({ - objects, + ids, }).min(1); export const exportTimelinesQuerySchema = Joi.object({ diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts index b56574f08158d..67697c347634e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/schemas/schemas.ts @@ -7,7 +7,7 @@ import Joi from 'joi'; /* eslint-disable @typescript-eslint/camelcase */ -export const objects = Joi.array().items(Joi.string()); +export const ids = Joi.array().items(Joi.string()); export const exclude_export_details = Joi.boolean(); export const file_name = Joi.string(); From 237d7f847dd17b9f927a8befd5a2d53f080712d5 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Wed, 18 Mar 2020 22:36:19 +0000 Subject: [PATCH 34/37] fix tests --- .../timeline/routes/__mocks__/request_responses.ts | 2 +- .../timeline/routes/export_timelines_route.test.ts | 2 +- .../lib/timeline/routes/export_timelines_route.ts | 2 +- .../siem/server/lib/timeline/routes/utils.ts | 13 +++++-------- .../plugins/siem/server/lib/timeline/types.ts | 2 +- 5 files changed, 9 insertions(+), 12 deletions(-) diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts index 364b976940ca1..eae1ece7e789d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -12,7 +12,7 @@ export const getExportTimelinesRequest = () => method: 'get', path: TIMELINE_EXPORT_URL, body: { - objects: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], + ids: ['f0e58720-57b6-11ea-b88d-3f1a31716be8', '890b8ae0-57df-11ea-a7c9-3976b7f1cb37'], }, }); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts index d445533274a75..fe434b5399212 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.test.ts @@ -87,7 +87,7 @@ describe('export timelines', () => { const request = requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, - body: { id: ['someId'] }, + body: { id: 'someId' }, }); const result = server.validate(request); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts index 944a886b0edee..3ded959aced36 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/export_timelines_route.ts @@ -42,7 +42,7 @@ export const exportTimelinesRoute = (router: IRouter, config: LegacyServices['co const siemResponse = buildSiemResponse(response); const savedObjectsClient = context.core.savedObjects.client; const exportSizeLimit = config().get('savedObjects.maxImportExportSize'); - if (request.body?.objects != null && request.body.objects.length > exportSizeLimit) { + if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { return siemResponse.error({ statusCode: 400, body: `Can't export more than ${exportSizeLimit} timelines`, diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index 58cb16e74c7ab..9c6b51c0752b0 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -127,21 +127,18 @@ const getTimelinesFromObjects = async ( savedObjectsClient: ExportTimelineSavedObjectsClient, request: ExportTimelineRequest ): Promise => { - const timelines: TimelineSavedObject[] = await getTimelines( - savedObjectsClient, - request.body.objects - ); + const timelines: TimelineSavedObject[] = await getTimelines(savedObjectsClient, request.body.ids); // To Do for feature freeze - // if (timelines.length !== request.body.objects.length) { + // if (timelines.length !== request.body.ids.length) { // //figure out which is missing to tell user // } const [notes, pinnedEventIds] = await Promise.all([ Promise.all( - request.body.objects.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) + request.body.ids.map(timelineId => getNotesByTimelineId(savedObjectsClient, timelineId)) ), Promise.all( - request.body.objects.map(timelineId => + request.body.ids.map(timelineId => getPinnedEventsByTimelineId(savedObjectsClient, timelineId) ) ), @@ -157,7 +154,7 @@ const getTimelinesFromObjects = async ( [] ); - const myResponse = request.body.objects.reduce((acc, timelineId) => { + const myResponse = request.body.ids.reduce((acc, timelineId) => { const myTimeline = timelines.find(t => t.savedObjectId === timelineId); if (myTimeline != null) { const timelineNotes = myNotes.filter(n => n.timelineId === timelineId); diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts index bbf6c4a3e40b8..35bf86c17db7e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/types.ts @@ -205,7 +205,7 @@ export interface AllTimelineSavedObject extends runtimeTypes.TypeOf {} export interface ExportTimelineRequestParams { - body: { objects: string[] }; + body: { ids: string[] }; query: { file_name: string; exclude_export_details: boolean; From b1465a84d037ea6bde43c25dbd1441df8e6200b4 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 19 Mar 2020 13:35:37 +0000 Subject: [PATCH 35/37] review --- .../components/generic_downloader/index.tsx | 7 ++++--- .../delete_timeline_modal.tsx | 17 ----------------- .../components/open_timeline/open_timeline.tsx | 4 +--- 3 files changed, 5 insertions(+), 23 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx index 9ef42bb1d7cd5..6f08f5c8c381c 100644 --- a/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/generic_downloader/index.tsx @@ -55,9 +55,8 @@ export const GenericDownloaderComponent = ({ const exportData = async () => { if (anchorRef && anchorRef.current && ids != null && ids.length > 0) { - let exportResponse; try { - exportResponse = await exportSelectedData({ + const exportResponse = await exportSelectedData({ ids, signal: abortCtrl.signal, }); @@ -81,7 +80,9 @@ export const GenericDownloaderComponent = ({ } } catch (error) { if (isSubscribed) { - if (onExportFailure != null) onExportFailure(); + if (onExportFailure != null) { + onExportFailure(); + } errorToToaster({ title: i18n.EXPORT_FAILURE, error, dispatchToaster }); } } diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 674cb96b5e504..34c6a7a0c95fa 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -54,20 +54,3 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel )); DeleteTimelineModal.displayName = 'DeleteTimelineModal'; - -export const useDeleteTimelineModal = () => { - const [isDeleteTimelineModalOpen, setIsDeleteTimelineModalOpen] = useState(false); - - const onCloseDeleteTimelineModal = useCallback(() => { - setIsDeleteTimelineModalOpen(false); - }, []); - - const onOpenDeleteTimelineModal = useCallback(() => { - setIsDeleteTimelineModalOpen(true); - }, []); - return { - isDeleteTimelineModalOpen, - onCloseDeleteTimelineModal, - onOpenDeleteTimelineModal, - }; -}; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index 8de5db3f5ba5c..c4d3aa1ca261a 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -104,9 +104,7 @@ export const OpenTimeline = React.memo( isDeleteTimelineModalOpen={isDeleteTimelineModalOpen} isEnableDownloader={isEnableDownloader} onComplete={onCompleteEditTimelineAction} - title={ - actionItem != null && actionItem.title ? actionItem.title : i18n.SELECTED_TIMELINES(1) - } + title={actionItem?.title ?? i18n.SELECTED_TIMELINES(1)} /> From 6aaaca2db417318eda775fff4e9862187766737b Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 19 Mar 2020 18:32:22 +0000 Subject: [PATCH 36/37] fix title of delete modal --- .../delete_timeline_modal.test.tsx | 4 +- .../delete_timeline_modal.tsx | 53 +++++++++---------- .../delete_timeline_modal/index.tsx | 2 +- .../edit_timeline_batch_actions.tsx | 2 +- .../open_timeline/open_timeline.tsx | 2 +- .../siem/server/lib/timeline/routes/utils.ts | 5 +- 6 files changed, 34 insertions(+), 34 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index 8a9aa4b53d41f..bb8f9b807c030 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -15,7 +15,7 @@ describe('DeleteTimelineModal', () => { test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( @@ -32,7 +32,7 @@ describe('DeleteTimelineModal', () => { test('it trims leading whitespace around the title', () => { const wrapper = mountWithIntl( diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 34c6a7a0c95fa..5a712381c4f52 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -6,51 +6,50 @@ import { EuiConfirmModal, EUI_MODAL_CONFIRM_BUTTON } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; +import { isEmpty } from 'lodash/fp'; import * as i18n from '../translations'; interface Props { - title?: string | JSX.Element | null; + title?: string | null; onDelete: () => void; closeModal: () => void; } export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px -const getDeletedTitles = (title: string | JSX.Element | null | undefined) => { - if (title != null && React.isValidElement(title)) { - return title; - } else if (title != null && typeof title === 'string' && title.trim().length > 0) { - return title.trim(); - } - return `"${i18n.UNTITLED_TIMELINE}"`; -}; - /** * Renders a modal that confirms deletion of a timeline */ -export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => ( - (({ title, closeModal, onDelete }) => { + const getTitle = useCallback(() => { + const trimmedTitle = title != null ? title.trim() : ''; + const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; + return ( - } - > -
{i18n.DELETE_WARNING}
-
-)); + ); + }, [title]); + return ( + +
{i18n.DELETE_WARNING}
+
+ ); +}); DeleteTimelineModal.displayName = 'DeleteTimelineModal'; diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx index ad82eb574db6a..df01ebacb1f93 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/index.tsx @@ -21,7 +21,7 @@ interface Props { onComplete?: () => void; isModalOpen: boolean; savedObjectIds: string[]; - title: string | JSX.Element | null; + title: string | null; } /** * Renders a button that when clicked, displays the `Delete Timeline` modal diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx index 6a93c21d2cc40..74b9a8cad98dc 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/edit_timeline_batch_actions.tsx @@ -74,7 +74,7 @@ export const useEditTimelinBatchActions = ({ title={ selectedItems?.length !== 1 ? i18n.SELECTED_TIMELINES(selectedItems?.length ?? 0) - : `"${selectedItems[0]?.title}"` + : selectedItems[0]?.title ?? '' } /> diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx index c4d3aa1ca261a..b1b100349eb86 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/open_timeline.tsx @@ -104,7 +104,7 @@ export const OpenTimeline = React.memo( isDeleteTimelineModalOpen={isDeleteTimelineModalOpen} isEnableDownloader={isEnableDownloader} onComplete={onCompleteEditTimelineAction} - title={actionItem?.title ?? i18n.SELECTED_TIMELINES(1)} + title={actionItem?.title ?? i18n.UNTITLED_TIMELINE} /> diff --git a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts index 9c6b51c0752b0..066862e025833 100644 --- a/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/timeline/routes/utils.ts @@ -80,16 +80,17 @@ const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): Expor return ( currentNotes.reduce((acc, note) => { - if (note.eventId == null) + if (note.eventId == null) { return { ...acc, globalNotes: [...acc.globalNotes, note], }; - else + } else { return { ...acc, eventNotes: [...acc.eventNotes, note], }; + } }, initialNotes) ?? initialNotes ); }; From 9bc0a4efafdfbfd733ab54449e9d9845d24e2807 Mon Sep 17 00:00:00 2001 From: Angela Chuang Date: Thu, 19 Mar 2020 20:05:06 +0000 Subject: [PATCH 37/37] remove an extra bracket --- .../delete_timeline_modal/delete_timeline_modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 5a712381c4f52..026c43feeff9b 100644 --- a/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/legacy/plugins/siem/public/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -29,7 +29,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel return (