diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx index 7203e74fe02c1e..54eb8cf2769a94 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.test.tsx @@ -88,7 +88,9 @@ describe('Action menu', () => { ); - expect(screen.getByTestId('attach-timeline-case-button')).toBeInTheDocument(); + expect( + screen.getByTestId('timeline-modal-attach-to-case-dropdown-button') + ).toBeInTheDocument(); }); it('does not render the button when the user does not have create permissions', () => { @@ -104,7 +106,9 @@ describe('Action menu', () => { ); - expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('timeline-modal-attach-to-case-dropdown-button') + ).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx index b1be7562fcf97e..9c30205d107020 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/action_menu/index.tsx @@ -8,12 +8,12 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; +import { AttachToCaseButton } from '../../modal/actions/attach_to_case_button'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import { APP_ID } from '../../../../../common'; import type { TimelineTabs } from '../../../../../common/types'; import { InspectButton } from '../../../../common/components/inspect'; import { InputsModelId } from '../../../../common/store/inputs/constants'; -import { AddToCaseButton } from '../add_to_case_button'; import { NewTimelineAction } from './new_timeline'; import { SaveTimelineButton } from './save_timeline_button'; import { OpenTimelineButton } from '../../modal/actions/open_timeline_button'; @@ -71,7 +71,7 @@ const TimelineActionMenuComponent = ({ - + ) : null} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.test.tsx similarity index 52% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx rename to x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.test.tsx index 063d6da3f2d072..22a9be583329d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.test.tsx @@ -6,14 +6,11 @@ */ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; - import { useKibana } from '../../../../common/lib/kibana'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { mockTimelineModel, TestProviders } from '../../../../common/mock'; -import { AddToCaseButton } from '.'; +import { AttachToCaseButton } from './attach_to_case_button'; import { SecurityPageName } from '../../../../../common/constants'; jest.mock('../../../../common/components/link_to', () => { @@ -26,28 +23,54 @@ jest.mock('../../../../common/components/link_to', () => { }), }; }); -const mockDispatch = jest.fn(); jest.mock('react-redux', () => { const original = jest.requireActual('react-redux'); return { ...original, - useDispatch: () => mockDispatch, + useDispatch: () => jest.fn(), + useSelector: () => mockTimelineModel, }; }); - jest.mock('../../../../common/lib/kibana'); -jest.mock('../../../../common/hooks/use_selector'); const useKibanaMock = useKibana as jest.Mocked; -describe('AddToCaseButton', () => { +const renderAttachToCaseButton = () => + render( + + + + ); + +describe('AttachToCaseButton', () => { const navigateToApp = jest.fn(); beforeEach(() => { useKibanaMock().services.application.navigateToApp = navigateToApp; }); - it('navigates to the correct path without id', async () => { + it('should render the 2 options in the popover when clicking on the button', () => { + const { getByTestId } = renderAttachToCaseButton(); + + const button = getByTestId('timeline-modal-attach-to-case-dropdown-button'); + + expect(button).toBeInTheDocument(); + expect(button).toHaveTextContent('Attach to case'); + + button.click(); + + expect(getByTestId('timeline-modal-attach-timeline-to-new-case')).toBeInTheDocument(); + expect(getByTestId('timeline-modal-attach-timeline-to-new-case')).toHaveTextContent( + 'Attach to new case' + ); + + expect(getByTestId('timeline-modal-attach-timeline-to-existing-case')).toBeInTheDocument(); + expect(getByTestId('timeline-modal-attach-timeline-to-existing-case')).toHaveTextContent( + 'Attach to existing case' + ); + }); + + it('should navigate to the create case page when clicking on attach to new case', async () => { const here = jest.fn(); useKibanaMock().services.cases.ui.getAllCasesSelectorModal = here.mockImplementation( ({ onRowClick }) => { @@ -55,16 +78,14 @@ describe('AddToCaseButton', () => { return <>; } ); - (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); - render( - - - - ); - userEvent.click(screen.getByTestId('attach-timeline-case-button')); + + const { getByTestId } = renderAttachToCaseButton(); + + getByTestId('timeline-modal-attach-to-case-dropdown-button').click(); + await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('attach-timeline-existing-case')); + getByTestId('timeline-modal-attach-timeline-to-existing-case').click(); expect(navigateToApp).toHaveBeenCalledWith('securitySolutionUI', { path: '/create', @@ -72,23 +93,21 @@ describe('AddToCaseButton', () => { }); }); - it('navigates to the correct path with id', async () => { + it('should open modal and navigate to the case page when clicking on attach to existing case', async () => { useKibanaMock().services.cases.ui.getAllCasesSelectorModal = jest .fn() .mockImplementation(({ onRowClick }) => { onRowClick({ id: 'case-id' }); return <>; }); - (useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel); - render( - - - - ); - userEvent.click(screen.getByTestId('attach-timeline-case-button')); + + const { getByTestId } = renderAttachToCaseButton(); + + getByTestId('timeline-modal-attach-to-case-dropdown-button').click(); + await waitForEuiPopoverOpen(); - userEvent.click(screen.getByTestId('attach-timeline-existing-case')); + getByTestId('timeline-modal-attach-timeline-to-existing-case').click(); expect(navigateToApp).toHaveBeenCalledWith('securitySolutionUI', { path: '/case-id', diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx rename to x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.tsx index 4eb701c5a8d98b..fb9f6d9edcbc7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_to_case_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/attach_to_case_button.tsx @@ -5,34 +5,33 @@ * 2.0. */ -import { pick } from 'lodash/fp'; import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import { useDispatch } from 'react-redux'; - +import { useDispatch, useSelector } from 'react-redux'; import type { CaseUI } from '@kbn/cases-plugin/common'; +import { UNTITLED_TIMELINE } from '../../timeline/properties/translations'; +import { selectTimelineById } from '../../../store/selectors'; +import type { State } from '../../../../common/store'; import { APP_ID, APP_UI_ID } from '../../../../../common/constants'; -import { timelineSelectors } from '../../../store'; import { setInsertTimeline, showTimeline } from '../../../store/actions'; -import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { useKibana } from '../../../../common/lib/kibana'; import { TimelineId } from '../../../../../common/types/timeline'; import { TimelineStatus, TimelineType } from '../../../../../common/api/timeline'; import { getCreateCaseUrl, getCaseDetailsUrl } from '../../../../common/components/link_to'; import { SecurityPageName } from '../../../../app/types'; -import { timelineDefaults } from '../../../store/defaults'; -import * as i18n from '../../timeline/properties/translations'; +import * as i18n from './translations'; -interface Props { +interface AttachToCaseButtonProps { + /** + * Id of the timeline to be displayed in the bottom bar and within the modal + */ timelineId: string; } -const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { - cases, - application: { navigateToApp }, - } = useKibana().services; +/** + * Button that opens a popover with options to attach the timeline to new or existing case + */ +export const AttachToCaseButton = React.memo(({ timelineId }) => { const dispatch = useDispatch(); const { graphEventId, @@ -40,18 +39,23 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { status: timelineStatus, title: timelineTitle, timelineType, - } = useDeepEqualSelector((state) => - pick( - ['graphEventId', 'savedObjectId', 'status', 'title', 'timelineType'], - getTimeline(state, timelineId) ?? timelineDefaults - ) - ); + } = useSelector((state: State) => selectTimelineById(state, timelineId)); + + const { + cases, + application: { navigateToApp }, + } = useKibana().services; + const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); + const [isPopoverOpen, setPopover] = useState(false); const [isCaseModalOpen, openCaseModal] = useState(false); + const togglePopover = useCallback(() => setPopover((currentIsOpen) => !currentIsOpen), []); + const closeCaseModal = useCallback(() => openCaseModal(false), [openCaseModal]); + const onRowClick = useCallback( async (theCase?: CaseUI) => { - openCaseModal(false); + closeCaseModal(); await navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.case, path: theCase != null ? getCaseDetailsUrl({ id: theCase.id }) : getCreateCaseUrl(), @@ -65,19 +69,19 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { }) ); }, - [dispatch, graphEventId, navigateToApp, savedObjectId, timelineId, timelineTitle] + [ + closeCaseModal, + dispatch, + graphEventId, + navigateToApp, + savedObjectId, + timelineId, + timelineTitle, + ] ); - const userCasesPermissions = cases.helpers.canUseCases([APP_ID]); - - const handleButtonClick = useCallback(() => { - setPopover((currentIsOpen) => !currentIsOpen); - }, []); - - const handlePopoverClose = useCallback(() => setPopover(false), []); - - const handleNewCaseClick = useCallback(() => { - handlePopoverClose(); + const attachToNewCase = useCallback(() => { + togglePopover(); navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.case, @@ -88,7 +92,7 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { graphEventId, timelineId, timelineSavedObjectId: savedObjectId, - timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, + timelineTitle: timelineTitle.length > 0 ? timelineTitle : UNTITLED_TIMELINE, }) ); dispatch(showTimeline({ id: TimelineId.active, show: false })); @@ -97,84 +101,71 @@ const AddToCaseButtonComponent: React.FC = ({ timelineId }) => { dispatch, graphEventId, navigateToApp, - handlePopoverClose, savedObjectId, timelineId, timelineTitle, + togglePopover, ]); - const handleExistingCaseClick = useCallback(() => { - handlePopoverClose(); + const attachToExistingCase = useCallback(() => { + togglePopover(); openCaseModal(true); - }, [openCaseModal, handlePopoverClose]); - - const onCaseModalClose = useCallback(() => { - openCaseModal(false); - }, [openCaseModal]); - - const closePopover = useCallback(() => { - setPopover(false); - }, []); + }, [togglePopover, openCaseModal]); const button = useMemo( () => ( {i18n.ATTACH_TO_CASE} ), - [handleButtonClick, timelineStatus, timelineType] + [togglePopover, timelineStatus, timelineType] ); const items = useMemo( () => [ {i18n.ATTACH_TO_NEW_CASE} , {i18n.ATTACH_TO_EXISTING_CASE} , ], - [handleExistingCaseClick, handleNewCaseClick] + [attachToExistingCase, attachToNewCase] ); return ( <> {isCaseModalOpen && cases.ui.getAllCasesSelectorModal({ onRowClick, - onClose: onCaseModalClose, + onClose: closeCaseModal, owner: [APP_ID], permissions: userCasesPermissions, })} ); -}; - -AddToCaseButtonComponent.displayName = 'AddToCaseButtonComponent'; +}); -export const AddToCaseButton = React.memo(AddToCaseButtonComponent); +AttachToCaseButton.displayName = 'AttachToCaseButton'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/translations.ts index 364e7eeb8c9a90..a30c77bd3632b2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/modal/actions/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/modal/actions/translations.ts @@ -20,3 +20,23 @@ export const OPEN_TIMELINE_BTN_LABEL = i18n.translate( defaultMessage: 'Open Existing Timeline', } ); + +export const ATTACH_TO_CASE = i18n.translate( + 'xpack.securitySolution.timeline.modal.attachToCaseButtonLabel', + { + defaultMessage: 'Attach to case', + } +); + +export const ATTACH_TO_NEW_CASE = i18n.translate( + 'xpack.securitySolution.timeline.modal.attachToNewCaseButtonLabel', + { + defaultMessage: 'Attach to new case', + } +); +export const ATTACH_TO_EXISTING_CASE = i18n.translate( + 'xpack.securitySolution.timeline.modal.attachToExistingCaseButtonLabel', + { + defaultMessage: 'Attach to existing case', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index aa1bf0ad308572..c037ae5d94cfaa 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -52,23 +52,3 @@ export const ADD_TIMELINE = i18n.translate( defaultMessage: 'Add new timeline or template', } ); - -export const ATTACH_TO_CASE = i18n.translate( - 'xpack.securitySolution.timeline.properties.attachToCaseButtonLabel', - { - defaultMessage: 'Attach to case', - } -); - -export const ATTACH_TO_NEW_CASE = i18n.translate( - 'xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel', - { - defaultMessage: 'Attach to new case', - } -); -export const ATTACH_TO_EXISTING_CASE = i18n.translate( - 'xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel', - { - defaultMessage: 'Attach to existing case', - } -); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 13277d812a4524..9633397e242dac 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -36107,9 +36107,6 @@ "xpack.securitySolution.timeline.participantsTitle": "Participants", "xpack.securitySolution.timeline.promptDeleteNoteLabel": "Supprimer la note sur la chronologie ?", "xpack.securitySolution.timeline.properties.addTimelineButtonLabel": "Ajouter une nouvelle chronologie ou un nouveau modèle", - "xpack.securitySolution.timeline.properties.attachToCaseButtonLabel": "Attacher à un cas", - "xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel": "Attacher à un cas existant", - "xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel": "Attacher au nouveau cas", "xpack.securitySolution.timeline.properties.descriptionPlaceholder": "Ajouter une description", "xpack.securitySolution.timeline.properties.lockDatePickerDescription": "Verrouiller le sélecteur de date global sur le sélecteur de date de chronologie", "xpack.securitySolution.timeline.properties.lockDatePickerTooltip": "Désactiver la synchronisation de la plage de date/heure entre la page actuellement consultée et votre chronologie", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1742ae147ca255..4698cc060b7d9e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -36107,9 +36107,6 @@ "xpack.securitySolution.timeline.participantsTitle": "参加者", "xpack.securitySolution.timeline.promptDeleteNoteLabel": "タイムラインメモを削除しますか?", "xpack.securitySolution.timeline.properties.addTimelineButtonLabel": "新しいタイムラインまたはテンプレートの追加", - "xpack.securitySolution.timeline.properties.attachToCaseButtonLabel": "ケースに関連付ける", - "xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel": "既存のケースに添付", - "xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel": "新しいケースに添付", "xpack.securitySolution.timeline.properties.descriptionPlaceholder": "説明を追加", "xpack.securitySolution.timeline.properties.lockDatePickerDescription": "グローバル日付ピッカーをタイムライン日付ピッカーにロック", "xpack.securitySolution.timeline.properties.lockDatePickerTooltip": "現在表示中のページとタイムラインの間の日付/時刻範囲の同期を無効にします", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f88e19663d0574..9a99a93093d678 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -36089,9 +36089,6 @@ "xpack.securitySolution.timeline.participantsTitle": "参与者", "xpack.securitySolution.timeline.promptDeleteNoteLabel": "删除时间线备注?", "xpack.securitySolution.timeline.properties.addTimelineButtonLabel": "添加新时间线或模板", - "xpack.securitySolution.timeline.properties.attachToCaseButtonLabel": "附加到案例", - "xpack.securitySolution.timeline.properties.attachToExistingCaseButtonLabel": "附加到现有案例", - "xpack.securitySolution.timeline.properties.attachToNewCaseButtonLabel": "附加到新案例", "xpack.securitySolution.timeline.properties.descriptionPlaceholder": "添加描述", "xpack.securitySolution.timeline.properties.lockDatePickerDescription": "将全局日期选取器锁定到时间线日期选取器", "xpack.securitySolution.timeline.properties.lockDatePickerTooltip": "禁用当前查看的页面与您的时间线之间的日期/时间范围同步", diff --git a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts index fb2859842842b9..8e37caf4633258 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/timeline.ts @@ -13,12 +13,14 @@ export const ADD_NOTE_BUTTON = '[data-test-subj="add-note"]'; export const ADD_FILTER = '[data-test-subj="timeline-search-or-filter"] [data-test-subj="addFilter"]'; -export const ATTACH_TIMELINE_TO_CASE_BUTTON = '[data-test-subj="attach-timeline-case-button"]'; +export const ATTACH_TIMELINE_TO_CASE_BUTTON = + '[data-test-subj="timeline-modal-attach-to-case-dropdown-button"]'; -export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = '[data-test-subj="attach-timeline-new-case"]'; +export const ATTACH_TIMELINE_TO_NEW_CASE_ICON = + '[data-test-subj="timeline-modal-attach-timeline-to-new-case"]'; export const ATTACH_TIMELINE_TO_EXISTING_CASE_ICON = - '[data-test-subj="attach-timeline-existing-case"]'; + '[data-test-subj="timeline-modal-attach-timeline-to-existing-case"]'; export const SELECT_CASE = (id: string) => { return `[data-test-subj="cases-table-row-select-${id}"]`;