diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 5cea37347b565..571cef8f6c484 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -146,10 +146,7 @@ export const getBreadcrumbsForRoute = ( } if (isTimelinesRoutes(spyState) && object.navTabs) { const tempNav: SearchNavTab = { urlKey: 'timeline', isDetailPage: false }; - let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; - if (spyState.tabName != null) { - urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; - } + const urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; return [ siemRootBreadcrumb, diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index ae8a00d2b4aa0..004e675cb3516 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -113,7 +113,7 @@ const StatefulRecentTimelinesComponent: React.FC = ({ apolloClient, filte )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx index 49f68281ae103..c130ea4c96814 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.test.tsx @@ -7,12 +7,26 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModal } from './delete_timeline_modal'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + test('it renders the expected title when a timeline is selected', () => { const wrapper = mountWithIntl( { /> ); - expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual(i18n.DELETE_WARNING); + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_WARNING + ); }); test('it invokes closeModal when the Cancel button is clicked', () => { @@ -115,3 +131,23 @@ describe('DeleteTimelineModal', () => { expect(onDelete).toBeCalled(); }); }); + +describe('DeleteTimelineTemplateModal', () => { + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.template }); + }); + + test('it renders a deletion warning', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="warning"]').first().text()).toEqual( + i18n.DELETE_TIMELINE_TEMPLATE_WARNING + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx index 5538610487899..f0efda6528507 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/delete_timeline_modal.tsx @@ -10,7 +10,9 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; +import { useParams } from 'react-router-dom'; import * as i18n from '../translations'; +import { TimelineType } from '../../../../../common/types/timeline'; interface Props { title?: string | null; @@ -24,6 +26,12 @@ export const DELETE_TIMELINE_MODAL_WIDTH = 600; // px * Renders a modal that confirms deletion of a timeline */ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDelete }) => { + const { tabName } = useParams<{ tabName: TimelineType }>(); + const warning = + tabName === TimelineType.template + ? i18n.DELETE_TIMELINE_TEMPLATE_WARNING + : i18n.DELETE_TIMELINE_WARNING; + const getTitle = useCallback(() => { const trimmedTitle = title != null ? title.trim() : ''; const titleResult = !isEmpty(trimmedTitle) ? trimmedTitle : i18n.UNTITLED_TIMELINE; @@ -48,7 +56,7 @@ export const DeleteTimelineModal = React.memo(({ title, closeModal, onDel onConfirm={onDelete} title={getTitle()} > -
{i18n.DELETE_WARNING}
+
{warning}
); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx index 18a6ffc06941c..cfbc7d255062f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/delete_timeline_modal/index.test.tsx @@ -7,8 +7,18 @@ import { mountWithIntl } from '@kbn/test/jest'; import React from 'react'; +import { useParams } from 'react-router-dom'; import { DeleteTimelineModalOverlay } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useParams: jest.fn(), + }; +}); describe('DeleteTimelineModal', () => { const savedObjectId = 'abcd'; @@ -20,6 +30,10 @@ describe('DeleteTimelineModal', () => { title: 'Privilege Escalation', }; + beforeAll(() => { + (useParams as jest.Mock).mockReturnValue({ tabName: TimelineType.default }); + }); + describe('showModalState', () => { test('it does NOT render the modal when isModalOpen is false', () => { const testProps = { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 24b0702770d3c..5a3da748bea1d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -122,9 +122,9 @@ export const OpenTimeline = React.memo( const onRefreshBtnClick = useCallback(() => { if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [refetch, searchResults, totalSearchResultsCount]); + }, [refetch]); const handleCloseModal = useCallback(() => { if (setImportDataModalToggle != null) { @@ -137,9 +137,9 @@ export const OpenTimeline = React.memo( setImportDataModalToggle(false); } if (refetch != null) { - refetch(searchResults, totalSearchResultsCount); + refetch(); } - }, [setImportDataModalToggle, refetch, searchResults, totalSearchResultsCount]); + }, [setImportDataModalToggle, refetch]); const actionTimelineToShow = useMemo(() => { const timelineActions: ActionTimelineToShow[] = ['createFrom', 'duplicate']; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 8c553bb95e9bd..c1b30f3e68cf4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -25,7 +25,11 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; -import { TimelineTypeLiteralWithNull, TimelineStatus } from '../../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatus, + TimelineType, +} from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -103,7 +107,7 @@ export interface TimelinesTableProps { onToggleShowNotes: OnToggleShowNotes; pageIndex: number; pageSize: number; - searchResults: OpenTimelineResult[]; + searchResults: OpenTimelineResult[] | null; showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; @@ -196,6 +200,13 @@ export const TimelinesTable = React.memo( ] ); + const noItemsMessage = + isLoading || searchResults == null + ? i18n.LOADING + : timelineType === TimelineType.template + ? i18n.ZERO_TIMELINE_TEMPLATES_MATCH + : i18n.ZERO_TIMELINES_MATCH; + return ( ( isSelectable={actionTimelineToShow.includes('selectable')} itemId="savedObjectId" itemIdToExpandedRowMap={itemIdToExpandedNotesRowMap} - items={searchResults} + items={searchResults ?? []} loading={isLoading} - noItemsMessage={i18n.ZERO_TIMELINES_MATCH} + noItemsMessage={noItemsMessage} onChange={onTableChange} pagination={pagination} selection={actionTimelineToShow.includes('selectable') ? selection : undefined} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index ae743ad30eef1..4858bf3ed6083 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -33,13 +33,21 @@ export const DELETE_SELECTED = i18n.translate( } ); -export const DELETE_WARNING = i18n.translate( +export const DELETE_TIMELINE_WARNING = i18n.translate( 'xpack.securitySolution.open.timeline.deleteWarningLabel', { defaultMessage: 'You will not be able to recover this timeline or its notes once deleted.', } ); +export const DELETE_TIMELINE_TEMPLATE_WARNING = i18n.translate( + 'xpack.securitySolution.open.timeline.deleteTemplateWarningLabel', + { + defaultMessage: + 'You will not be able to recover this timeline template or its notes once deleted.', + } +); + export const DESCRIPTION = i18n.translate( 'xpack.securitySolution.open.timeline.descriptionTableHeader', { @@ -204,6 +212,10 @@ export const WITH = i18n.translate('xpack.securitySolution.open.timeline.withLab defaultMessage: 'with', }); +export const LOADING = i18n.translate('xpack.securitySolution.open.timeline.loadingLabel', { + defaultMessage: 'Loading...', +}); + export const ZERO_TIMELINES_MATCH = i18n.translate( 'xpack.securitySolution.open.timeline.zeroTimelinesMatchLabel', { @@ -211,6 +223,13 @@ export const ZERO_TIMELINES_MATCH = i18n.translate( } ); +export const ZERO_TIMELINE_TEMPLATES_MATCH = i18n.translate( + 'xpack.securitySolution.open.timeline.zeroTimelineTemplatesMatchLabel', + { + defaultMessage: '0 timeline templates match the search criteria', + } +); + export const SINGLE_TIMELINE = i18n.translate( 'xpack.securitySolution.open.timeline.singleTimelineLabel', { @@ -305,14 +324,14 @@ export const FILTER_CUSTOM_TIMELINES = i18n.translate( export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', { - defaultMessage: 'Import timeline', + defaultMessage: 'Import', } ); export const SELECT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', { - defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import', + defaultMessage: 'Select a timeline or timeline template file to import', } ); @@ -343,14 +362,14 @@ export const SUCCESSFULLY_IMPORTED_TIMELINES = (totalCount: number) => export const IMPORT_FAILED = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importFailedTitle', { - defaultMessage: 'Failed to import timelines', + defaultMessage: 'Failed to import', } ); export const IMPORT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTitle', { - defaultMessage: 'Import timelineā€¦', + defaultMessage: 'Importā€¦', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index f5ba9959ae898..680bb6f975983 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -167,9 +167,9 @@ export interface OpenTimelineProps { /** The currently applied search criteria */ query: string; /** Refetch table */ - refetch?: (existingTimeline?: OpenTimelineResult[], existingCount?: number) => void; - /** The results of executing a search */ - searchResults: OpenTimelineResult[]; + refetch?: () => void; + /** The results of executing a search, null is the status before data fatched */ + searchResults: OpenTimelineResult[] | null; /** the currently-selected timelines in the table */ selectedItems: OpenTimelineResult[]; /** Toggle export timelines modal*/ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index d4de77e04e9f7..7ccce80bbe9a4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -222,7 +222,7 @@ const SelectableTimelineComponent: React.FC = ({ windowProps: { onScroll: ({ scrollOffset }) => handleOnScroll( - timelines.filter((t) => !hideUntitled || t.title !== '').length, + (timelines ?? []).filter((t) => !hideUntitled || t.title !== '').length, timelineCount, scrollOffset ), @@ -254,7 +254,7 @@ const SelectableTimelineComponent: React.FC = ({ = ({ searchProps={searchProps} singleSelection={true} options={getSelectableOptions({ - timelines, + timelines: timelines ?? [], onlyFavorites, searchTimelineValue, timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index b14ccbd319399..82b41a95bd537 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -38,7 +38,7 @@ export interface AllTimelinesArgs { status, timelineType, }: AllTimelinesVariables) => void; - timelines: OpenTimelineResult[]; + timelines: OpenTimelineResult[] | null; loading: boolean; totalCount: number; customTemplateTimelineCount: number; @@ -105,7 +105,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const [allTimelines, setAllTimelines] = useState>({ loading: false, totalCount: 0, - timelines: [], + timelines: null, // use null as initial state to distinguish between empty result and haven't started loading. customTemplateTimelineCount: 0, defaultTimelineCount: 0, elasticTemplateTimelineCount: 0, @@ -128,7 +128,10 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines((prevState) => ({ ...prevState, loading: true })); + setAllTimelines((prevState) => ({ + ...prevState, + loading: true, + })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, diff --git a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx index 53ea28832f47f..806ac57df1f65 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/index.tsx @@ -12,7 +12,6 @@ import { Switch, Route, useHistory } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../../src/core/public'; import { TimelineType } from '../../../common/types/timeline'; -import { TAB_TIMELINES, TAB_TEMPLATES } from '../components/open_timeline/translations'; import { TimelineRouteSpyState } from '../../common/utils/route/types'; import { TimelinesPage } from './timelines_page'; @@ -25,37 +24,18 @@ import { SecurityPageName } from '../../app/types'; const timelinesPagePath = `/:tabName(${TimelineType.default}|${TimelineType.template})`; const timelinesDefaultPath = `/${TimelineType.default}`; -const TabNameMappedToI18nKey: Record = { - [TimelineType.default]: TAB_TIMELINES, - [TimelineType.template]: TAB_TEMPLATES, -}; - export const getBreadcrumbs = ( params: TimelineRouteSpyState, search: string[], getUrlForApp: GetUrlForApp -): ChromeBreadcrumb[] => { - let breadcrumb = [ - { - text: PAGE_TITLE, - href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { - path: !isEmpty(search[0]) ? search[0] : '', - }), - }, - ]; - - const tabName = params?.tabName; - if (!tabName) return breadcrumb; - - breadcrumb = [ - ...breadcrumb, - { - text: TabNameMappedToI18nKey[tabName], - href: '', - }, - ]; - return breadcrumb; -}; +): ChromeBreadcrumb[] => [ + { + text: PAGE_TITLE, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.timelines}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, +]; export const Timelines = React.memo(() => { const history = useHistory(); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index f3bff98785619..199fc27c2663a 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -21,7 +21,7 @@ export const ALL_TIMELINES_PANEL_TITLE = i18n.translate( export const ALL_TIMELINES_IMPORT_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.timelines.allTimelines.importTimelineTitle', { - defaultMessage: 'Import Timeline', + defaultMessage: 'Import', } );