Skip to content

Commit

Permalink
[Security Solution][Timeline] refactor timeline modal attach to case …
Browse files Browse the repository at this point in the history
…button (#175163)
  • Loading branch information
PhilippeOberti authored Jan 24, 2024
1 parent 2f8825d commit 19f01ae
Show file tree
Hide file tree
Showing 10 changed files with 133 additions and 126 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ describe('Action menu', () => {
</TestProviders>
);

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', () => {
Expand All @@ -104,7 +106,9 @@ describe('Action menu', () => {
</TestProviders>
);

expect(screen.queryByTestId('attach-timeline-case-button')).not.toBeInTheDocument();
expect(
screen.queryByTestId('timeline-modal-attach-to-case-dropdown-button')
).not.toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -71,7 +71,7 @@ const TimelineActionMenuComponent = ({
<VerticalDivider />
</EuiFlexItem>
<EuiFlexItem>
<AddToCaseButton timelineId={timelineId} />
<AttachToCaseButton timelineId={timelineId} />
</EuiFlexItem>
</>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -26,69 +23,91 @@ 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<typeof useKibana>;

describe('AddToCaseButton', () => {
const renderAttachToCaseButton = () =>
render(
<TestProviders>
<AttachToCaseButton timelineId={'timeline-1'} />
</TestProviders>
);

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 }) => {
onRowClick();
return <></>;
}
);
(useDeepEqualSelector as jest.Mock).mockReturnValue(mockTimelineModel);
render(
<TestProviders>
<AddToCaseButton timelineId={'timeline-1'} />
</TestProviders>
);
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',
deepLinkId: SecurityPageName.case,
});
});

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(
<TestProviders>
<AddToCaseButton timelineId={'timeline-1'} />
</TestProviders>
);
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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,57 @@
* 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<Props> = ({ 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<AttachToCaseButtonProps>(({ timelineId }) => {
const dispatch = useDispatch();
const {
graphEventId,
savedObjectId,
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(),
Expand All @@ -65,19 +69,19 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ 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,
Expand All @@ -88,7 +92,7 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ 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 }));
Expand All @@ -97,84 +101,71 @@ const AddToCaseButtonComponent: React.FC<Props> = ({ 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(
() => (
<EuiButtonEmpty
size="m"
data-test-subj="attach-timeline-case-button"
iconType="arrowDown"
iconSide="right"
onClick={handleButtonClick}
disabled={timelineStatus === TimelineStatus.draft || timelineType !== TimelineType.default}
data-test-subj="timeline-modal-attach-to-case-dropdown-button"
onClick={togglePopover}
>
{i18n.ATTACH_TO_CASE}
</EuiButtonEmpty>
),
[handleButtonClick, timelineStatus, timelineType]
[togglePopover, timelineStatus, timelineType]
);

const items = useMemo(
() => [
<EuiContextMenuItem
key="new-case"
data-test-subj="attach-timeline-new-case"
onClick={handleNewCaseClick}
data-test-subj="timeline-modal-attach-timeline-to-new-case"
onClick={attachToNewCase}
>
{i18n.ATTACH_TO_NEW_CASE}
</EuiContextMenuItem>,
<EuiContextMenuItem
key="existing-case"
data-test-subj="attach-timeline-existing-case"
onClick={handleExistingCaseClick}
data-test-subj="timeline-modal-attach-timeline-to-existing-case"
onClick={attachToExistingCase}
>
{i18n.ATTACH_TO_EXISTING_CASE}
</EuiContextMenuItem>,
],
[handleExistingCaseClick, handleNewCaseClick]
[attachToExistingCase, attachToNewCase]
);

return (
<>
<EuiPopover
id="singlePanel"
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
closePopover={togglePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
{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';
Loading

0 comments on commit 19f01ae

Please sign in to comment.