Skip to content

Commit

Permalink
[Security Solution] Unified Timeline - Add Expandable Flyout + fixes …
Browse files Browse the repository at this point in the history
…old flyout (#181793)

## Summary

This feature must be enabled with below feature flag:

```yaml
xpack.securitySolution.enableExperimental:
  - unifiedComponentsInTimelineEnabled

```

This PR enables expandable Flyout in unified timeline and fixes the
`z-index` issue with previous flyout.

This is essentially a workaround until either
#180646 or
#180645 is resolved. After that
#179520 will make sure to remove
this workaround in favour of a permanent solution.

This is how it looks after the fix: 


https://github.com/elastic/kibana/assets/7485038/21952311-92bf-49a4-a8fd-1d12c126bd5c
  • Loading branch information
logeekal committed Apr 30, 2024
1 parent 52ea57a commit d4c6e07
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ interface DetailsPanelProps {
isReadOnly?: boolean;
}

const detailsPanelStyleProp = { zIndex: 1001 };

/**
* This panel is used in both the main timeline as well as the flyouts on the host, detection, cases, and network pages.
* To prevent duplication the `isFlyoutView` prop is passed to determine the layout that should be used
Expand Down Expand Up @@ -169,6 +171,7 @@ export const DetailsPanel = React.memo(
<EuiFlyout
data-test-subj="timeline:details-panel:flyout"
size={panelSize}
style={detailsPanelStyleProp}
onClose={closePanel}
ownFocus={false}
key={flyoutUniqueKey}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { UnifiedDataTableProps } from '@kbn/unified-data-table';
import { UnifiedDataTable, DataLoadingState } from '@kbn/unified-data-table';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { EuiDataGridCustomBodyProps, EuiDataGridProps } from '@elastic/eui';
import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys';
import { selectTimelineById } from '../../../../store/selectors';
import { RowRendererCount } from '../../../../../../common/api/timeline';
import { EmptyComponent } from '../../../../../common/lib/cell_actions/helpers';
Expand Down Expand Up @@ -43,6 +44,7 @@ import { transformTimelineItemToUnifiedRows } from '../utils';
import { TimelineEventDetailRow } from './timeline_event_detail_row';
import { CustomTimelineDataGridBody } from './custom_timeline_data_grid_body';
import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants';
import { useUnifiedTableExpandableFlyout } from '../hooks/use_unified_timeline_expandable_flyout';

export const SAMPLE_SIZE_SETTING = 500;
const DataGridMemoized = React.memo(UnifiedDataTable);
Expand Down Expand Up @@ -124,6 +126,15 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord & TimelineItem>();
const [fetchedPage, setFechedPage] = useState<number>(0);

const onCloseExpandableFlyout = useCallback(() => {
setExpandedDoc((prev) => (!prev ? prev : undefined));
}, []);

const { openFlyout, closeFlyout, isTimelineExpandableFlyoutEnabled } =
useUnifiedTableExpandableFlyout({
onClose: onCloseExpandableFlyout,
});

const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.timeline);

const showTimeCol = useMemo(() => !!dataView && !!dataView.timeFieldName, [dataView]);
Expand All @@ -142,26 +153,39 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
const updatedExpandedDetail: ExpandedDetailType = {
panelView: 'eventDetail',
params: {
eventId: eventData.id,
indexName: eventData._index ?? '', // TODO: fix type error
eventId: eventData._id,
indexName: eventData.ecs._index ?? '', // TODO: fix type error
refetch,
},
};

dispatch(
timelineActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType: activeTab,
id: timelineId,
})
);
if (isTimelineExpandableFlyoutEnabled) {
openFlyout({
right: {
id: DocumentDetailsRightPanelKey,
params: {
id: eventData._id,
indexName: eventData.ecs._index ?? '',
scopeId: timelineId,
},
},
});
} else {
dispatch(
timelineActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType: activeTab,
id: timelineId,
})
);
}

activeTimeline.toggleExpandedDetail({ ...updatedExpandedDetail });
},
[activeTab, dispatch, refetch, timelineId]
[activeTab, dispatch, refetch, timelineId, isTimelineExpandableFlyoutEnabled, openFlyout]
);

const handleOnPanelClosed = useCallback(() => {
const onTimelineLegacyFlyoutClose = useCallback(() => {
if (
expandedDetail[activeTab]?.panelView &&
timelineId === TimelineId.active &&
Expand All @@ -182,10 +206,20 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
handleOnEventDetailPanelOpened(timelineDoc);
}
} else {
handleOnPanelClosed();
if (isTimelineExpandableFlyoutEnabled) {
closeFlyout();
return;
}
onTimelineLegacyFlyoutClose();
}
},
[tableRows, handleOnEventDetailPanelOpened, handleOnPanelClosed]
[
tableRows,
handleOnEventDetailPanelOpened,
onTimelineLegacyFlyoutClose,
closeFlyout,
isTimelineExpandableFlyoutEnabled,
]
);

const onColumnResize = useCallback(
Expand Down Expand Up @@ -393,10 +427,10 @@ export const TimelineDataTableComponent: React.FC<DataTableProps> = memo(
trailingControlColumns={trailingControlColumns}
renderCustomGridBody={renderCustomBodyCallback}
/>
{showExpandedDetails && (
{showExpandedDetails && !isTimelineExpandableFlyoutEnabled && (
<DetailsPanel
browserFields={browserFields}
handleOnPanelClosed={handleOnPanelClosed}
handleOnPanelClosed={onTimelineLegacyFlyoutClose}
runtimeMappings={runtimeMappings}
tabType={activeTab}
scopeId={timelineId}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* 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 { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { renderHook } from '@testing-library/react-hooks';
import { useUnifiedTableExpandableFlyout } from './use_unified_timeline_expandable_flyout';
import { useLocation } from 'react-router-dom';
import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';

jest.mock('../../../../../common/hooks/use_experimental_features');
jest.mock('@kbn/kibana-react-plugin/public');
jest.mock('react-router-dom', () => {
return {
useLocation: jest.fn(),
};
});
jest.mock('@kbn/expandable-flyout');

const onFlyoutCloseMock = jest.fn();

describe('useUnifiedTimelineExpandableFlyout', () => {
beforeEach(() => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
(useUiSetting$ as jest.Mock).mockReturnValue([true, jest.fn()]);
(useLocation as jest.Mock).mockReturnValue({
search: `?${URL_PARAM_KEY.timelineFlyout}=(test:value)`,
});
(useExpandableFlyoutApi as jest.Mock).mockReturnValue({
openFlyout: jest.fn(),
closeFlyout: jest.fn(),
});
});

afterEach(() => {
jest.clearAllMocks();
});

it('should have expandable flyout disabled when flyout is disabled in Advanced Settings', () => {
(useUiSetting$ as jest.Mock).mockReturnValue([false, jest.fn()]);
const { result } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);

expect(result.current.isTimelineExpandableFlyoutEnabled).toBe(false);
});
it('should have expandable flyout disabled when flyout is disabled in Experimental Features', () => {
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);

expect(result.current.isTimelineExpandableFlyoutEnabled).toBe(false);
});
describe('when flyout is enabled', () => {
it('should mark flyout as closed when location is empty', () => {
(useLocation as jest.Mock).mockReturnValue({
search: '',
});

const { result } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);

expect(result.current.isTimelineExpandableFlyoutOpen).toBe(false);
});

it('should mark flyout as open when location has `timelineFlyout`', () => {
(useLocation as jest.Mock).mockReturnValue({
search: `${URL_PARAM_KEY.timelineFlyout}=(test:value)`,
});
const { result } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);

expect(result.current.isTimelineExpandableFlyoutOpen).toBe(true);
});

it('should mark flyout as close when location has empty `timelineFlyout`', () => {
const { result, rerender } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);
expect(result.current.isTimelineExpandableFlyoutOpen).toBe(true);

(useLocation as jest.Mock).mockReturnValue({
search: `${URL_PARAM_KEY.timelineFlyout}=()`,
});

rerender();

expect(result.current.isTimelineExpandableFlyoutOpen).toBe(false);
expect(onFlyoutCloseMock).toHaveBeenCalledTimes(1);
});

it('should call user provided close handler when flyout is closed', () => {
const { result } = renderHook(() =>
useUnifiedTableExpandableFlyout({
onClose: onFlyoutCloseMock,
})
);

result.current.closeFlyout();
expect(onFlyoutCloseMock).toHaveBeenCalledTimes(1);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/*
* 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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { useExpandableFlyoutApi } from '@kbn/expandable-flyout';
import { useLocation } from 'react-router-dom';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { ENABLE_EXPANDABLE_FLYOUT_SETTING } from '../../../../../../common/constants';
import { URL_PARAM_KEY } from '../../../../../common/hooks/use_url_state';

const EMPTY_TIMELINE_FLYOUT_SEARCH_PARAMS = '()';

interface UseUnifiedTableExpandableFlyoutArgs {
onClose?: () => void;
}

export const useUnifiedTableExpandableFlyout = ({
onClose,
}: UseUnifiedTableExpandableFlyoutArgs) => {
const expandableTimelineFlyoutEnabled = useIsExperimentalFeatureEnabled(
'expandableTimelineFlyoutEnabled'
);

const [isSecurityFlyoutEnabled] = useUiSetting$<boolean>(ENABLE_EXPANDABLE_FLYOUT_SETTING);

const location = useLocation();

const { openFlyout, closeFlyout } = useExpandableFlyoutApi();

const closeFlyoutWithEffect = useCallback(() => {
closeFlyout();
onClose?.();
}, [onClose, closeFlyout]);

const isFlyoutOpen = useMemo(() => {
/**
* Currently, if new expanable flyout is closed, there is not way for
* consumer to trigger an effect `onClose` of flyout. So, we are using
* this hack to know if flyout is open or not.
*
* Raised: https://github.com/elastic/kibana/issues/179520
*
* */
const searchParams = new URLSearchParams(location.search);
return (
searchParams.has(URL_PARAM_KEY.timelineFlyout) &&
searchParams.get(URL_PARAM_KEY.timelineFlyout) !== EMPTY_TIMELINE_FLYOUT_SEARCH_PARAMS
);
}, [location.search]);

const [isTimelineExpandableFlyoutOpen, setIsTimelineExpandableFlyoutOpen] =
useState(isFlyoutOpen);

useEffect(() => {
setIsTimelineExpandableFlyoutOpen((prev) => {
if (prev === isFlyoutOpen) {
return prev;
}
if (!isFlyoutOpen && onClose) {
// run onClose only when isFlyoutOpen changed from true to false
// should not be needed when
// https://github.com/elastic/kibana/issues/179520
// is resolved

onClose();
}
return isFlyoutOpen;
});
}, [isFlyoutOpen, onClose]);

return {
isTimelineExpandableFlyoutOpen,
openFlyout,
closeFlyout: closeFlyoutWithEffect,
isTimelineExpandableFlyoutEnabled: expandableTimelineFlyoutEnabled && isSecurityFlyoutEnabled,
};
};

0 comments on commit d4c6e07

Please sign in to comment.