Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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