Skip to content

Commit

Permalink
[RAC][Security Solution] Add to case actions in detail flyout (elasti…
Browse files Browse the repository at this point in the history
…c#108057)

* add to case action in flyout

* Fix most type errors

* Use context menu item instead of empty button for popover items

* Remove unused import

* Fire action on case modal close

* Update tests to use both components and remove console.log

* Update mocks in unit tests

* Use an onClose prop instead of closeCallbacks

* Pr feedback, create shared mock and rename handler

* Make app usable when timelines is not enabled

* Remove unused translations
  • Loading branch information
kqualters-elastic committed Aug 12, 2021
1 parent ccb3a97 commit f847d87
Show file tree
Hide file tree
Showing 26 changed files with 810 additions and 314 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface AllCasesSelectorModalProps extends Owner {
onRowClick: (theCase?: Case | SubCase) => void;
updateCase?: (newCase: Case) => void;
userCanCrud: boolean;
onClose?: () => void;
}

const Modal = styled(EuiModal)`
Expand All @@ -43,9 +44,15 @@ const AllCasesSelectorModalComponent: React.FC<AllCasesSelectorModalProps> = ({
onRowClick,
updateCase,
userCanCrud,
onClose,
}) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(true);
const closeModal = useCallback(() => setIsModalOpen(false), []);
const closeModal = useCallback(() => {
if (onClose) {
onClose();
}
setIsModalOpen(false);
}, [onClose]);
const onClick = useCallback(
(theCase?: Case | SubCase) => {
closeModal();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,33 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell
import { useTimelineEvents } from '../../../timelines/containers';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';

jest.mock('../../lib/kibana');
import { mockTimelines } from '../../mock/mock_timelines_plugin';

jest.mock('../../lib/kibana', () => ({
useKibana: () => ({
services: {
application: {
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(),
},
uiSettings: {
get: jest.fn(),
},
savedObjects: {
client: {},
},
timelines: { ...mockTimelines },
},
}),
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
}),
useGetUserCasesPermissions: jest.fn(),
useDateFormat: jest.fn(),
useTimeZone: jest.fn(),
}));

jest.mock('../../hooks/use_experimental_features');
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';

export const mockTimelines = {
getLastUpdated: jest.fn(),
getLoadingPanel: jest.fn(),
getFieldBrowser: jest.fn().mockReturnValue(<div data-test-subj="field-browser" />),
getUseDraggableKeyboardWrapper: () =>
jest.fn().mockReturnValue({
onBlur: jest.fn(),
onKeyDown: jest.fn(),
}),
getAddToCasePopover: jest
.fn()
.mockReturnValue(<div data-test-subj="add-to-case-action">{'Add to case'}</div>),
getAddToCaseAction: jest.fn(),
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,18 @@ import { TimelineEventsDetailsItem, TimelineNonEcsData } from '../../../../commo
import { useExceptionActions } from '../alerts_table/timeline_actions/use_add_exception_actions';
import { useAlertsActions } from '../alerts_table/timeline_actions/use_alerts_actions';
import { useInvestigateInTimeline } from '../alerts_table/timeline_actions/use_investigate_in_timeline';
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
import {
ACTION_ADD_TO_CASE
} from '../alerts_table/translations';
import { ACTION_ADD_TO_CASE } from '../alerts_table/translations';
import { useGetUserCasesPermissions, useKibana } from '../../../common/lib/kibana';
import { useInsertTimeline } from '../../../cases/components/use_insert_timeline';
import { addToCaseActionItem } from './helpers'; */
import { addToCaseActionItem } from './helpers';
import { useEventFilterAction } from '../alerts_table/timeline_actions/use_event_filter_action';
import { useHostIsolationAction } from '../host_isolation/use_host_isolation_action';
import { CHANGE_ALERT_STATUS } from './translations';
import { getFieldValue } from '../host_isolation/helpers';
import type { Ecs } from '../../../../common/ecs';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { endpointAlertCheck } from '../../../common/utils/endpoint_alert_check';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';

interface ActionsData {
alertStatus: Status;
Expand Down Expand Up @@ -64,11 +62,11 @@ export const TakeActionDropdown = React.memo(
onAddIsolationStatusClick: (action: 'isolateHost' | 'unisolateHost') => void;
timelineId: string;
}) => {
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
const casePermissions = useGetUserCasesPermissions();
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');

const { timelines: timelinesUi } = useKibana().services;
const insertTimelineHook = useInsertTimeline;
*/
const [isPopoverOpen, setIsPopoverOpen] = useState(false);

const actionsData = useMemo(
Expand Down Expand Up @@ -149,6 +147,10 @@ export const TakeActionDropdown = React.memo(
onAddEventFilterClick: handleOnAddEventFilterClick,
});

const afterCaseSelection = useCallback(() => {
closePopoverHandler();
}, [closePopoverHandler]);

const { actionItems } = useAlertsActions({
alertStatus: actionsData.alertStatus,
eventId: actionsData.eventId,
Expand Down Expand Up @@ -176,42 +178,76 @@ export const TakeActionDropdown = React.memo(
[eventFilterActions, exceptionActions, isEvent, actionsData.ruleId]
);

const panels = useMemo(
() => [
{
id: 0,
items: [
...alertsActionItems,
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
...addToCaseActionItem(timelineId),*/
...hostIsolationAction,
...investigateInTimelineAction,
],
},
{
id: 1,
title: CHANGE_ALERT_STATUS,
content: <EuiContextMenuPanel size="s" items={actionItems} />,
},
/* Todo: Uncomment case action after getAddToCaseAction is split into action and modal
{
id: 2,
title: ACTION_ADD_TO_CASE,
content: (
<>
{ecsData &&
timelinesUi.getAddToCaseAction({
ecsRowData: ecsData,
useInsertTimeline: insertTimelineHook,
casePermissions,
showIcon: false,
})}
</>
),
},*/
],
[actionItems, alertsActionItems, hostIsolationAction, investigateInTimelineAction]
);
const panels = useMemo(() => {
if (tGridEnabled) {
return [
{
id: 0,
items: [
...alertsActionItems,
...addToCaseActionItem(timelineId),
...hostIsolationAction,
...investigateInTimelineAction,
],
},
{
id: 1,
title: CHANGE_ALERT_STATUS,
content: <EuiContextMenuPanel size="s" items={actionItems} />,
},
{
id: 2,
title: ACTION_ADD_TO_CASE,
content: [
<>
{ecsData &&
timelinesUi.getAddToExistingCaseButton({
ecsRowData: ecsData,
useInsertTimeline: insertTimelineHook,
casePermissions,
appId: 'securitySolution',
onClose: afterCaseSelection,
})}
</>,
<>
{ecsData &&
timelinesUi.getAddToNewCaseButton({
ecsRowData: ecsData,
useInsertTimeline: insertTimelineHook,
casePermissions,
appId: 'securitySolution',
onClose: afterCaseSelection,
})}
</>,
],
},
];
} else {
return [
{
id: 0,
items: [...alertsActionItems, ...hostIsolationAction, ...investigateInTimelineAction],
},
{
id: 1,
title: CHANGE_ALERT_STATUS,
content: <EuiContextMenuPanel size="s" items={actionItems} />,
},
];
}
}, [
alertsActionItems,
hostIsolationAction,
investigateInTimelineAction,
ecsData,
casePermissions,
insertTimelineHook,
timelineId,
timelinesUi,
actionItems,
afterCaseSelection,
tGridEnabled,
]);

const takeActionButton = useMemo(() => {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,3 @@ export const CHANGE_ALERT_STATUS = i18n.translate(
defaultMessage: 'Change alert status',
}
);

export const ACTION_ADD_ENDPOINT_EXCEPTION = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addEndpointException',
{
defaultMessage: 'Add Endpoint exception',
}
);

export const ACTION_ADD_EXCEPTION = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addException',
{
defaultMessage: 'Add rule exception',
}
);

export const ACTION_ADD_EVENT_FILTER = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.addEventFilter',
{
defaultMessage: 'Add Endpoint event filter',
}
);

export const INVESTIGATE_IN_TIMELINE = i18n.translate(
'xpack.securitySolution.endpoint.takeAction.investigateInTimeline',
{
defaultMessage: 'investigate in timeline',
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import { useEventFilterModal } from '../../../../detections/components/alerts_ta
import { getFieldValue } from '../../../../detections/components/host_isolation/helpers';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import { useFetchEcsAlertsData } from '../../../../detections/containers/detection_engine/alerts/use_fetch_ecs_alerts_data';
import { Ecs } from '../../../../../common/ecs';

interface EventDetailsFooterProps {
detailsData: TimelineEventsDetailsItem[] | null;
expandedEvent: {
eventId: string;
indexName: string;
ecsData?: Ecs;
refetch?: () => void;
};
handleOnEventClosed: () => void;
Expand Down Expand Up @@ -103,6 +105,7 @@ export const EventDetailsFooter = React.memo(
<EuiFlexItem grow={false}>
<TakeActionDropdown
detailsData={detailsData}
ecsData={ecsData}
handleOnEventClosed={handleOnEventClosed}
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
loadingEventDetails={loadingEventDetails}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { DetailsPanel } from './index';
import { TimelineExpandedDetail, TimelineTabs } from '../../../../common/types/timeline';
import { FlowTarget } from '../../../../common/search_strategy/security_solution/network';

jest.mock('../../../common/lib/kibana');

describe('Details Panel Component', () => {
const state: State = { ...mockGlobalState };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TestProviders, mockTimelineModel, mockTimelineData } from '../../../../
import { Actions } from '.';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin';

jest.mock('../../../../../common/hooks/use_experimental_features');
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
Expand All @@ -20,13 +21,29 @@ jest.mock('../../../../../common/hooks/use_selector', () => ({
useShallowEqualSelector: jest.fn(),
}));

jest.mock('../../../../../common/lib/kibana', () => {
const useKibana = jest.requireActual('../../../../../common/lib/kibana');
return {
...useKibana,
useGetUserCasesPermissions: jest.fn(),
};
});
jest.mock('../../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
application: {
navigateToApp: jest.fn(),
getUrlForApp: jest.fn(),
},
uiSettings: {
get: jest.fn(),
},
savedObjects: {
client: {},
},
timelines: { ...mockTimelines },
},
}),
useToasts: jest.fn().mockReturnValue({
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
}),
useGetUserCasesPermissions: jest.fn(),
}));

describe('Actions', () => {
beforeEach(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
TimelineId.detectionsRulesDetailsPage,
TimelineId.active,
].includes(timelineId as TimelineId) &&
timelinesUi.getAddToCaseAction(addToCaseActionProps)}
timelinesUi.getAddToCasePopover(addToCaseActionProps)}
<AlertContextMenu
ariaLabel={i18n.MORE_ACTIONS_FOR_ROW({ ariaRowindex, columnValues })}
key="alert-context-menu"
Expand All @@ -196,6 +196,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
onRuleChange={onRuleChange}
/>
</>
{timelinesUi.getAddToCaseAction(addToCaseActionProps)}
</ActionsContainer>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,15 @@ import { useShallowEqualSelector } from '../../../../../common/hooks/use_selecto
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { defaultControlColumn } from '../control_columns';
import { testLeadingControlColumn } from '../../../../../common/mock/mock_timeline_control_columns';
import { mockTimelines } from '../../../../../common/mock/mock_timelines_plugin';

jest.mock('../../../../../common/hooks/use_experimental_features');
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
jest.mock('../../../../../common/hooks/use_selector');
jest.mock('../../../../../common/lib/kibana', () => ({
useKibana: () => ({
services: {
timelines: {
getAddToCaseAction: () => <div data-test-subj="add-to-case-action">{'Add to case'}</div>,
},
timelines: { ...mockTimelines },
},
}),
useToasts: jest.fn().mockReturnValue({
Expand All @@ -44,7 +43,7 @@ jest.mock(
'../../../../../../../timelines/public/components/actions/timeline/cases/add_to_case_action',
() => {
return {
AddToCaseAction: () => {
AddToCasePopover: () => {
return <div data-test-subj="add-to-case-action">{'Add to case'}</div>;
},
};
Expand Down
Loading

0 comments on commit f847d87

Please sign in to comment.