From 2a6326d0315b70a32f145c523b6f14e193edbb56 Mon Sep 17 00:00:00 2001 From: Jatin Kathuria Date: Wed, 16 Oct 2024 19:53:49 +0200 Subject: [PATCH] [Security Solution] Event Renderer Virtualization (#193316) ## Summary This PR implements virtualization when Event Renderers are enabled. Ideally from UX pespective nothing should change but from performance perspective, the event renderers should be scalable. ### Testing checklist 1. UX is working same as before when Event Renderers are enabled. 2. Operations such as increasing page size from `10` to `100` are not taking as much time as before. Below operations can be used to test. a. Closing / Opening Timeline b. Changes `Rows per page` c. Changes tabs from query to any other and back. ### Before In below video, you will notice how long it took to change `pageSize` to 100 and all 100 rows are rendered at once. https://github.com/user-attachments/assets/106669c9-bda8-4b7d-af3f-b64824bde397 ### After https://github.com/user-attachments/assets/356d9e1f-caf1-4f88-9223-0e563939bf6b > [!Note] > 1. Also test in small screen. The table should be scrollable but nothing out of ordinary. > 2. Additionally, try to load data which has `network_flow` process so as to create bigger and varied Event Renderers. --------- Co-authored-by: Cee Chen (cherry picked from commit fa92a8ede7bce32456e3d6a6307761b4209248f9) --- package.json | 2 +- .../security_solution/common/constants.ts | 5 + .../events/stateful_row_renderer/index.tsx | 118 ++++---- .../use_stateful_row_renderer.ts | 1 + .../shared/use_timeline_control_columns.tsx | 106 +++---- ...stom_timeline_data_grid_body.test.tsx.snap | 140 +++++---- .../custom_timeline_data_grid_body.test.tsx | 13 +- .../custom_timeline_data_grid_body.tsx | 271 ++++++++++++++---- .../unified_components/data_table/index.tsx | 53 ++-- .../timeline_event_detail_row.test.tsx | 15 +- .../data_table/timeline_event_detail_row.tsx | 4 +- .../timeline/unified_components/styles.tsx | 9 + .../rule_creation/indicator_match_rule.cy.ts | 11 +- yarn.lock | 8 +- 14 files changed, 491 insertions(+), 265 deletions(-) diff --git a/package.json b/package.json index feeea4800cdf0..6e29f954fcee9 100644 --- a/package.json +++ b/package.json @@ -1633,7 +1633,7 @@ "@types/react-router-dom": "^5.3.3", "@types/react-syntax-highlighter": "^15.4.0", "@types/react-test-renderer": "^17.0.2", - "@types/react-virtualized": "^9.21.22", + "@types/react-virtualized": "^9.21.30", "@types/react-window": "^1.8.8", "@types/react-window-infinite-loader": "^1.0.9", "@types/redux-actions": "^2.6.1", diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 877214641dc1e..2fd83a4849a75 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -513,3 +513,8 @@ export const CASE_ATTACHMENT_ENDPOINT_TYPE_ID = 'endpoint' as const; */ export const MAX_MANUAL_RULE_RUN_LOOKBACK_WINDOW_DAYS = 90; export const MAX_MANUAL_RULE_RUN_BULK_SIZE = 100; + +/* + * Whether it is a Jest environment + */ +export const JEST_ENVIRONMENT = typeof jest !== 'undefined'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx index b6df692dcabfd..3d50029f70315 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/index.tsx @@ -40,69 +40,67 @@ import { useStatefulRowRenderer } from './use_stateful_row_renderer'; * which focuses the current or next row, respectively. * - A screen-reader-only message provides additional context and instruction */ -export const StatefulRowRenderer = ({ - ariaRowindex, - containerRef, - event, - lastFocusedAriaColindex, - rowRenderers, - timelineId, -}: { - ariaRowindex: number; - containerRef: React.MutableRefObject; - event: TimelineItem; - lastFocusedAriaColindex: number; - rowRenderers: RowRenderer[]; - timelineId: string; -}) => { - const { focusOwnership, onFocus, onKeyDown, onOutsideClick } = useStatefulEventFocus({ +export const StatefulRowRenderer = React.memo( + ({ ariaRowindex, - colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, containerRef, + event, lastFocusedAriaColindex, - onColumnFocused: noop, - rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, - }); - - const { rowRenderer } = useStatefulRowRenderer({ - data: event.ecs, rowRenderers, - }); - - const content = useMemo( - () => - rowRenderer && ( - // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -
- - - -

{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}

-
- - - {rowRenderer.renderRow({ - data: event.ecs, - isDraggable: true, - scopeId: timelineId, - })} - - -
-
-
- ), - [ + timelineId, + }: { + ariaRowindex: number; + containerRef: React.MutableRefObject; + event: TimelineItem; + lastFocusedAriaColindex: number; + rowRenderers: RowRenderer[]; + timelineId: string; + }) => { + const { focusOwnership, onFocus, onKeyDown, onOutsideClick } = useStatefulEventFocus({ ariaRowindex, - event.ecs, - focusOwnership, - onFocus, - onKeyDown, - onOutsideClick, - rowRenderer, - timelineId, - ] - ); + colindexAttribute: ARIA_COLINDEX_ATTRIBUTE, + containerRef, + lastFocusedAriaColindex, + onColumnFocused: noop, + rowindexAttribute: ARIA_ROWINDEX_ATTRIBUTE, + }); + + const { rowRenderer } = useStatefulRowRenderer({ + data: event.ecs, + rowRenderers, + }); + + const row = useMemo(() => { + const result = rowRenderer?.renderRow({ + data: event.ecs, + isDraggable: false, + scopeId: timelineId, + }); + return result; + }, [rowRenderer, event.ecs, timelineId]); + + const content = useMemo( + () => + rowRenderer && ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
+ + + +

{i18n.YOU_ARE_IN_AN_EVENT_RENDERER(ariaRowindex)}

+
+ + {row} + +
+
+
+ ), + [ariaRowindex, focusOwnership, onFocus, onKeyDown, onOutsideClick, rowRenderer, row] + ); + + return content; + } +); - return content; -}; +StatefulRowRenderer.displayName = 'StatefulRowRenderer'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/use_stateful_row_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/use_stateful_row_renderer.ts index 504cbe94a9102..7648c94288907 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/use_stateful_row_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_row_renderer/use_stateful_row_renderer.ts @@ -17,6 +17,7 @@ interface UseStatefulRowRendererArgs { export function useStatefulRowRenderer(args: UseStatefulRowRendererArgs) { const { data, rowRenderers } = args; + const rowRenderer = useMemo(() => getRowRenderer({ data, rowRenderers }), [data, rowRenderers]); const result = useMemo( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx index 662baa8e6b665..beeaadb7829c8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/tabs/shared/use_timeline_control_columns.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useEffect } from 'react'; import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import type { SortColumnTable } from '@kbn/securitysolution-data-table'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; +import { JEST_ENVIRONMENT } from '../../../../../../common/constants'; import { useLicense } from '../../../../../common/hooks/use_license'; import { SourcererScopeName } from '../../../../../sourcerer/store/model'; import { useSourcererDataView } from '../../../../../sourcerer/containers'; @@ -21,6 +22,7 @@ import { TimelineControlColumnCellRender } from '../../unified_components/data_t import type { ColumnHeaderOptions } from '../../../../../../common/types'; import { useTimelineColumns } from './use_timeline_columns'; import type { UnifiedTimelineDataGridCellContext } from '../../types'; +import { useTimelineUnifiedDataTableContext } from '../../unified_components/data_table/use_timeline_unified_data_table_context'; interface UseTimelineControlColumnArgs { columns: ColumnHeaderOptions[]; @@ -59,6 +61,58 @@ export const useTimelineControlColumn = ({ const ACTION_BUTTON_COUNT = isEnterprisePlus ? 6 : 5; const { localColumns } = useTimelineColumns(columns); + const RowCellRender = useMemo( + () => + function TimelineControlColumnCellRenderer( + props: EuiDataGridCellValueElementProps & UnifiedTimelineDataGridCellContext + ) { + const ctx = useTimelineUnifiedDataTableContext(); + + useEffect(() => { + props.setCellProps({ + className: + ctx.expanded?.id === events[props.rowIndex]?._id + ? 'unifiedDataTable__cell--expanded' + : '', + }); + }); + + /* + * In some cases, when number of events is updated + * but new table is not yet rendered it can result + * in the mismatch between the number of events v/s + * the number of rows in the table currently rendered. + * + * */ + if ('rowIndex' in props && props.rowIndex >= events.length) return <>; + return ( + + ); + }, + [events, timelineId, refetch, pinnedEventIds, eventIdToNoteIds, onToggleShowNotes] + ); + // We need one less when the unified components are enabled because the document expand is provided by the unified data table const UNIFIED_COMPONENTS_ACTION_BUTTON_COUNT = ACTION_BUTTON_COUNT - 1; return useMemo(() => { @@ -84,49 +138,7 @@ export const useTimelineControlColumn = ({ /> ); }, - rowCellRender: ( - props: EuiDataGridCellValueElementProps & UnifiedTimelineDataGridCellContext - ) => { - /* - * In some cases, when number of events is updated - * but new table is not yet rendered it can result - * in the mismatch between the number of events v/s - * the number of rows in the table currently rendered. - * - * */ - if ('rowIndex' in props && props.rowIndex >= events.length) return <>; - props.setCellProps({ - className: - props.expandedEventId === events[props.rowIndex]?._id - ? 'unifiedDataTable__cell--expanded' - : '', - }); - - return ( - - ); - }, + rowCellRender: JEST_ENVIRONMENT ? RowCellRender : React.memo(RowCellRender), })); } else { return getDefaultControlColumn(ACTION_BUTTON_COUNT).map((x) => ({ @@ -142,11 +154,7 @@ export const useTimelineControlColumn = ({ sort, activeTab, timelineId, - refetch, - events, - pinnedEventIds, - eventIdToNoteIds, - onToggleShowNotes, ACTION_BUTTON_COUNT, + RowCellRender, ]); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap index d54175194b748..d5a892b4e54ce 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/__snapshots__/custom_timeline_data_grid_body.test.tsx.snap @@ -2,32 +2,35 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = ` .c0 { - width: -webkit-fit-content; - width: -moz-fit-content; - width: fit-content; - border-bottom: 1px solid 1px solid #343741; -} - -.c0 . euiDataGridRowCell--controlColumn { - height: auto; - min-height: 34px; + width: 100%; + height: 100%; + border-bottom: 1px solid #343741; } .c0 .udt--customRow { border-radius: 0; padding: 6px; - max-width: 1200px; - width: 85vw; + max-width: 1000px; } -.c0 .euiCommentEvent__body { - background-color: #1d1e24; +.c0 .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn .euiDataGridRowCell__content { + width: 1000px; + max-width: 1000px; + overflow-x: auto; + -webkit-scrollbar-width: thin; + -moz-scrollbar-width: thin; + -ms-scrollbar-width: thin; + scrollbar-width: thin; + -webkit-scroll-padding: 0 0 0 0,; + -moz-scroll-padding: 0 0 0 0,; + -ms-scroll-padding: 0 0 0 0,; + scroll-padding: 0 0 0 0,; } -.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--firstColumn, -.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--lastColumn, -.c0:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--controlColumn, -.c0:has(.unifiedDataTable__cell--expanded) .udt--customRow { +.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--firstColumn, +.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--lastColumn, +.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .euiDataGridRowCell--controlColumn, +.c0 .euiDataGridRow:has(.unifiedDataTable__cell--expanded) .udt--customRow { background-color: #2e2d25; } @@ -42,47 +45,76 @@ exports[`CustomTimelineDataGridBody should render exactly as snapshots 1`] = `
-
- Cell-0-0 -
-
- Cell-0-1 +
+
+
+
+
+
+ Cell-0-0 +
+
+ Cell-0-1 +
+
+ Cell-0-2 +
+
+
+ Cell-0-3 +
+
+
+
+
+
+
+ Cell-1-0 +
+
+ Cell-1-1 +
+
+ Cell-1-2 +
+
+
+ Cell-1-3 +
+
+
+
-
- Cell-0-2 -
-
-
- Cell-0-3 -
-
-
-
-
- Cell-1-0 -
-
- Cell-1-1 -
-
- Cell-1-2 -
-
-
- Cell-1-3
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx index b6d7f52f2d92f..cfdb2b0d2dbf9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.test.tsx @@ -48,10 +48,10 @@ const defaultProps: CustomTimelineDataGridBodyProps = { visibleColumns: mockVisibleColumns, headerRow: <>, footerRow: null, - gridWidth: 0, + gridWidth: 1000, }; -const renderTestComponents = (props?: CustomTimelineDataGridBodyProps) => { +const renderTestComponents = (props?: Partial) => { const finalProps = props ? { ...defaultProps, ...props } : defaultProps; return render( @@ -88,8 +88,15 @@ describe('CustomTimelineDataGridBody', () => { (useStatefulRowRenderer as jest.Mock).mockReturnValueOnce({ canShowRowRenderer: true, }); - const { getByText, queryByText } = renderTestComponents(); + const { getByTestId, getByText, queryByText } = renderTestComponents(); + + expect(getByTestId('customGridRowsContainer')).toBeVisible(); expect(queryByText('Cell-0-3')).toBeFalsy(); expect(getByText('Cell-1-3')).toBeInTheDocument(); }); + + it('should not render grid if gridWidth is 0', () => { + const { queryByTestId } = renderTestComponents({ gridWidth: 0 }); + expect(queryByTestId('customGridRowsContainer')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx index 559dcbf10c4e6..d03958bba5b62 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/custom_timeline_data_grid_body.tsx @@ -5,18 +5,24 @@ * 2.0. */ -import type { EuiDataGridCustomBodyProps } from '@elastic/eui'; +import type { EuiDataGridCustomBodyProps, EuiDataGridRowHeightsOptions } from '@elastic/eui'; import type { DataTableRecord } from '@kbn/discover-utils/types'; -import type { EuiTheme } from '@kbn/react-kibana-context-styled'; +import { type EuiTheme } from '@kbn/react-kibana-context-styled'; import type { TimelineItem } from '@kbn/timelines-plugin/common'; -import type { FC } from 'react'; -import React, { memo, useMemo } from 'react'; +import type { CSSProperties, FC, PropsWithChildren } from 'react'; +import React, { memo, useMemo, useState, useEffect, useRef, useCallback } from 'react'; import styled from 'styled-components'; +import { VariableSizeList } from 'react-window'; +import { EuiAutoSizer, useEuiTheme } from '@elastic/eui'; import type { RowRenderer } from '../../../../../../common/types'; import { TIMELINE_EVENT_DETAIL_ROW_ID } from '../../body/constants'; import { useStatefulRowRenderer } from '../../body/events/stateful_row_renderer/use_stateful_row_renderer'; import { getEventTypeRowClassName } from './get_event_type_row_classname'; +const defaultAutoHeight: EuiDataGridRowHeightsOptions = { + defaultHeight: 'auto', +}; + export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & { rows: Array | undefined; enabledRowRenderers: RowRenderer[]; @@ -24,9 +30,46 @@ export type CustomTimelineDataGridBodyProps = EuiDataGridCustomBodyProps & { refetch?: () => void; }; +const VirtualizedCustomDataGridContainer = styled.div<{ + $maxWidth?: number; +}>` + width: 100%; + height: 100%; + border-bottom: ${(props) => (props.theme as EuiTheme).eui.euiBorderThin}; + .udt--customRow { + border-radius: 0; + padding: ${(props) => (props.theme as EuiTheme).eui.euiDataGridCellPaddingM}; + max-width: ${(props) => props.$maxWidth}px; + } + + .euiDataGridRowCell--lastColumn.euiDataGridRowCell--controlColumn .euiDataGridRowCell__content { + width: ${(props) => props.$maxWidth}px; + max-width: ${(props) => props.$maxWidth}px; + overflow-x: auto; + scrollbar-width: thin; + scroll-padding: 0 0 0 0, + } + + .euiDataGridRow:has(.unifiedDataTable__cell--expanded) { + .euiDataGridRowCell--firstColumn, + .euiDataGridRowCell--lastColumn, + .euiDataGridRowCell--controlColumn, + .udt--customRow { + ${({ theme }) => `background-color: ${theme.eui.euiColorHighlight};`} + } + } + } +`; + // THE DataGrid Row default is 34px, but we make ours 40 to account for our row actions const DEFAULT_UDT_ROW_HEIGHT = 34; +const SCROLLBAR_STYLE: CSSProperties = { + scrollbarWidth: 'thin', + scrollPadding: '0 0 0 0', + overflow: 'auto', +}; + /** * * In order to render the additional row with every event ( which displays the row-renderer, notes and notes editor) @@ -44,40 +87,170 @@ export const CustomTimelineDataGridBody: FC = m function CustomTimelineDataGridBody(props) { const { Cell, - headerRow, - footerRow, visibleColumns, visibleRowData, rows, rowHeight, enabledRowRenderers, refetch, + setCustomGridBodyProps, + headerRow, + footerRow, + gridWidth, } = props; + const { euiTheme } = useEuiTheme(); + + // // Set custom props onto the grid body wrapper + const bodyRef = useRef(null); + useEffect(() => { + setCustomGridBodyProps({ + ref: bodyRef, + style: { + width: '100%', + height: '100%', + overflowY: 'hidden', + scrollbarColor: `${euiTheme.colors.mediumShade} ${euiTheme.colors.lightestShade}`, + }, + }); + }, [setCustomGridBodyProps, euiTheme.colors.mediumShade, euiTheme.colors.lightestShade]); + const visibleRows = useMemo( () => (rows ?? []).slice(visibleRowData.startRow, visibleRowData.endRow), [rows, visibleRowData] ); + const listRef = useRef>(null); + + const rowHeights = useRef([]); + + const setRowHeight = useCallback((index: number, height: number) => { + if (rowHeights.current[index] === height) return; + listRef.current?.resetAfterIndex(index); + + rowHeights.current[index] = height; + }, []); + + const getRowHeight = useCallback((index: number) => { + return rowHeights.current[index] ?? 100; + }, []); + + /* + * + * There is a difference between calculatedWidth & gridWidth + * + * gridWidth is the width of the grid as per the screen size + * + * calculatedWidth is the width of the grid that is calculated by EUI and represents + * the actual width of the grid based on the content of the grid. ( Sum of the width of all columns) + * + * For example, screensize can be variable but calculatedWidth can be much more than that + * with grid having a horizontal scrollbar + * + * + * */ + const [calculatedWidth, setCalculatedWidth] = useState(gridWidth); + + useEffect(() => { + /* + * Any time gridWidth(available screen size) is changed, we need to re-check + * to see if EUI has changed the width of the grid + * + */ + if (!bodyRef) return; + const headerRowRef = bodyRef?.current?.querySelector('.euiDataGridHeader[role="row"]'); + setCalculatedWidth((prev) => + headerRowRef?.clientWidth && headerRowRef?.clientWidth !== prev + ? headerRowRef?.clientWidth + : prev + ); + }, [gridWidth]); + + const innerRowContainer = useMemo(() => { + const InnerComp = React.forwardRef< + HTMLDivElement, + PropsWithChildren<{ style: CSSProperties }> + >(({ children, style, ...rest }, ref) => { + return ( + <> + {headerRow} +
+ {children} +
+ + {footerRow} + + ); + }); + + InnerComp.displayName = 'InnerRowContainer'; + + return React.memo(InnerComp); + }, [headerRow, footerRow]); + return ( - <> - {headerRow} - {visibleRows.map((row, rowIndex) => { - return ( - - ); - })} - {footerRow} - + + + {({ height }) => { + return ( + <> + { + /** + * whenever timeline is minimized, VariableList is re-rendered which causes delay, + * so below code makes sure that grid is only rendered when gridWidth is not 0 + */ + gridWidth !== 0 && ( + <> + + {({ index, style }) => { + return ( +
+ +
+ ); + }} +
+ + ) + } + + ); + }} +
+
); } ); @@ -85,41 +258,17 @@ export const CustomTimelineDataGridBody: FC = m /** * * A Simple Wrapper component for displaying a custom grid row + * Generating CSS on this row puts a huge performance overhead on the grid as each row much styled individually. + * If possible, try to use the styles either in ../styles.tsx or in the parent component * */ + const CustomGridRow = styled.div.attrs<{ className?: string; }>((props) => ({ className: `euiDataGridRow ${props.className ?? ''}`, role: 'row', -}))` - width: fit-content; - border-bottom: 1px solid ${(props) => (props.theme as EuiTheme).eui.euiBorderThin}; - . euiDataGridRowCell--controlColumn { - height: ${(props: { $cssRowHeight: string }) => props.$cssRowHeight}; - min-height: ${DEFAULT_UDT_ROW_HEIGHT}px; - } - .udt--customRow { - border-radius: 0; - padding: ${(props) => (props.theme as EuiTheme).eui.euiDataGridCellPaddingM}; - max-width: ${(props) => (props.theme as EuiTheme).eui.euiPageDefaultMaxWidth}; - width: 85vw; - } - - .euiCommentEvent__body { - background-color: ${(props) => (props.theme as EuiTheme).eui.euiColorEmptyShade}; - } - - &:has(.unifiedDataTable__cell--expanded) { - .euiDataGridRowCell--firstColumn, - .euiDataGridRowCell--lastColumn, - .euiDataGridRowCell--controlColumn, - .udt--customRow { - ${({ theme }) => `background-color: ${theme.eui.euiColorHighlight};`} - } - } - } -`; +}))``; /* below styles as per : https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer */ const CustomGridRowCellWrapper = styled.div.attrs<{ @@ -138,6 +287,7 @@ const CustomGridRowCellWrapper = styled.div.attrs<{ type CustomTimelineDataGridSingleRowProps = { rowData: DataTableRecord & TimelineItem; rowIndex: number; + setRowHeight: (index: number, height: number) => void; } & Pick< CustomTimelineDataGridBodyProps, 'visibleColumns' | 'Cell' | 'enabledRowRenderers' | 'refetch' | 'rowHeight' @@ -168,13 +318,24 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( visibleColumns, Cell, rowHeight: rowHeightMultiple = 0, + setRowHeight, } = props; + const { canShowRowRenderer } = useStatefulRowRenderer({ data: rowData.ecs, rowRenderers: enabledRowRenderers, }); + const rowRef = useRef(null); + + useEffect(() => { + if (rowRef.current) { + setRowHeight(rowIndex, rowRef.current.offsetHeight); + } + }, [rowIndex, setRowHeight]); + const cssRowHeight: string = calculateRowHeightInPixels(rowHeightMultiple - 1); + /** * removes the border between the actual row ( timelineEvent) and `TimelineEventDetail` row * which renders the row-renderer, notes and notes editor @@ -194,12 +355,11 @@ const CustomDataGridSingleRow = memo(function CustomDataGridSingleRow( return ( {visibleColumns.map((column, colIndex) => { - // Skip the expanded row cell - we'll render it manually outside of the flex wrapper if (column.id !== TIMELINE_EVENT_DETAIL_ROW_ID) { return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx index e4862fe8d72f6..875c147d6a700 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/index.tsx @@ -12,8 +12,13 @@ import type { DataTableRecord } from '@kbn/discover-utils/types'; 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 type { + EuiDataGridControlColumn, + EuiDataGridCustomBodyProps, + EuiDataGridProps, +} from '@elastic/eui'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { JEST_ENVIRONMENT } from '../../../../../../common/constants'; import { useOnExpandableFlyoutClose } from '../../../../../flyout/shared/hooks/use_on_expandable_flyout_close'; import { DocumentDetailsRightPanelKey } from '../../../../../flyout/document_details/shared/constants/panel_keys'; import { selectTimelineById } from '../../../../store/selectors'; @@ -43,7 +48,6 @@ 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 type { UnifiedTimelineDataGridCellContext } from '../../types'; export const SAMPLE_SIZE_SETTING = 500; const DataGridMemoized = React.memo(UnifiedDataTable); @@ -288,6 +292,23 @@ export const TimelineDataTableComponent: React.FC = memo( return rowRenderers.filter((rowRenderer) => !excludedRowRendererIds.includes(rowRenderer.id)); }, [excludedRowRendererIds, rowRenderers]); + const TimelineEventDetailRowRendererComp = useMemo( + () => + function TimelineEventDetailRowRenderer(props) { + const { rowIndex, ...restProps } = props; + return ( + + ); + }, + [tableRows, timelineId, enabledRowRenderers] + ); + /** * Ref: https://eui.elastic.co/#/tabular-content/data-grid-advanced#custom-body-renderer */ @@ -295,31 +316,20 @@ export const TimelineDataTableComponent: React.FC = memo( () => [ { id: TIMELINE_EVENT_DETAIL_ROW_ID, - // The header cell should be visually hidden, but available to screen readers width: 0, + // The header cell should be visually hidden, but available to screen readers headerCellRender: () => <>, headerCellProps: { className: 'euiScreenReaderOnly' }, // The footer cell can be hidden to both visual & SR users, as it does not contain meaningful information footerCellProps: { style: { display: 'none' } }, - // When rendering this custom cell, we'll want to override - // the automatic width/heights calculated by EuiDataGrid - rowCellRender: (props) => { - const { rowIndex, ...restProps } = props; - return ( - - ); - }, + rowCellRender: JEST_ENVIRONMENT + ? TimelineEventDetailRowRendererComp + : React.memo(TimelineEventDetailRowRendererComp), }, ], - [enabledRowRenderers, tableRows, timelineId] + [TimelineEventDetailRowRendererComp] ); /** @@ -352,12 +362,6 @@ export const TimelineDataTableComponent: React.FC = memo( [tableRows, enabledRowRenderers, rowHeight, refetch] ); - const cellContext: UnifiedTimelineDataGridCellContext = useMemo(() => { - return { - expandedEventId: expandedDoc?.id, - }; - }, [expandedDoc]); - const finalRenderCustomBodyCallback = useMemo(() => { return enabledRowRenderers.length > 0 ? renderCustomBodyCallback : undefined; }, [enabledRowRenderers.length, renderCustomBodyCallback]); @@ -419,7 +423,6 @@ export const TimelineDataTableComponent: React.FC = memo( renderCustomGridBody={finalRenderCustomBodyCallback} trailingControlColumns={finalTrailControlColumns} externalControlColumns={leadingControlColumns} - cellContext={cellContext} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.test.tsx index a4de0c7dfa318..92070ec1fada0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.test.tsx @@ -18,7 +18,14 @@ const mockData = structuredClone(mockTimelineData); const setCellPropsMock = jest.fn(); -jest.mock('../../body/events/stateful_row_renderer'); +jest.mock('../../body/events/stateful_row_renderer', () => { + return { + StatefulRowRenderer: jest.fn(), + }; +}); + +const StatefulRowRendererMock = StatefulRowRenderer as unknown as jest.Mock; + jest.mock('./use_timeline_unified_data_table_context'); const renderTestComponent = (props: Partial = {}) => { @@ -44,7 +51,7 @@ const renderTestComponent = (props: Partial = {}) = describe('TimelineEventDetailRow', () => { beforeEach(() => { - (StatefulRowRenderer as jest.Mock).mockReturnValue(
{'Test Row Renderer'}
); + StatefulRowRendererMock.mockReturnValue(
{'Test Row Renderer'}
); (useTimelineUnifiedDataTableContext as jest.Mock).mockReturnValue({ expanded: { id: undefined }, @@ -60,7 +67,7 @@ describe('TimelineEventDetailRow', () => { expect(setCellPropsMock).toHaveBeenCalledWith({ className: '', - style: { width: '100%', height: 'auto' }, + style: { width: '100%', height: undefined, overflowX: 'auto' }, }); expect(getByText('Test Row Renderer')).toBeVisible(); @@ -82,7 +89,7 @@ describe('TimelineEventDetailRow', () => { expect(setCellPropsMock).toHaveBeenCalledWith({ className: 'unifiedDataTable__cell--expanded', - style: { width: '100%', height: 'auto' }, + style: { width: '100%', height: undefined, overflowX: 'auto' }, }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.tsx index 72a33af797210..9a3bc97254962 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/unified_components/data_table/timeline_event_detail_row.tsx @@ -60,7 +60,7 @@ export const TimelineEventDetailRow: React.FC = mem useEffect(() => { setCellProps?.({ className: ctx.expanded?.id === event._id ? 'unifiedDataTable__cell--expanded' : '', - style: { width: '100%', height: 'auto' }, + style: { width: '100%', height: undefined, overflowX: 'auto' }, }); }, [ctx.expanded?.id, setCellProps, rowIndex, event._id]); @@ -72,7 +72,7 @@ export const TimelineEventDetailRow: React.FC = mem alignItems="center" data-test-subj={`timeline-row-renderer-${rowIndex}`} > - + + `scrollbar-color: ${theme.eui.euiColorMediumShade} ${theme.eui.euiColorLightShade}`}; + } + .udtTimeline [data-gridcell-column-id|='select'] { border-right: none; } @@ -182,6 +187,10 @@ export const StyledTimelineUnifiedDataTable = styled.div.attrs(({ className = '' align-items: baseline; } + .euiDataGrid__customRenderBody { + scrollbar-color: transparent !important; + } + ${leadingActionsColumnStyles} `; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts index d359a50c00c3e..8d44be4dc3aaf 100644 --- a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/detection_engine/rule_creation/indicator_match_rule.cy.ts @@ -503,8 +503,6 @@ describe('indicator match', { tags: ['@ess', '@serverless', '@skipInServerlessMK }); it('Investigate alert in timeline', () => { - const accessibilityText = `Press enter for options, or press space to begin dragging.`; - loadPrepackagedTimelineTemplates(); createRule(getNewThreatIndicatorRule({ rule_id: 'rule_testing', enabled: true })).then( (rule) => visitRuleDetailsPage(rule.body.id) @@ -525,14 +523,9 @@ describe('indicator match', { tags: ['@ess', '@serverless', '@skipInServerlessMK cy.get(INDICATOR_MATCH_ROW_RENDER).should( 'have.text', - `threat.enrichments.matched.field${ - getNewThreatIndicatorRule().threat_mapping[0].entries[0].field - }${accessibilityText}matched${ - getNewThreatIndicatorRule().threat_mapping[0].entries[0].field - }${ + `${getNewThreatIndicatorRule().threat_mapping[0].entries[0].field}matched${ indicatorRuleMatchingDoc.atomic - }${accessibilityText}threat.enrichments.matched.typeindicator_match_rule${accessibilityText}provided` + - ` byfeed.nameAbuseCH malware${accessibilityText}` + }indicator_match_ruleprovided` + ` byAbuseCH malware` ); }); }); diff --git a/yarn.lock b/yarn.lock index d3125cf7fa7d3..65b4f0cd741a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11320,10 +11320,10 @@ dependencies: "@types/react" "*" -"@types/react-virtualized@^9.21.22": - version "9.21.22" - resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.22.tgz#5ba39b29869200620a6bf2069b8393f258a9c1e2" - integrity sha512-YRifyCKnBG84+J/Hny0f3bo8BRrcNT74CvsAVpQpZcS83fdC7lP7RfzwL2ND8/ihhpnDFL1IbxJ9MpQNaKUDuQ== +"@types/react-virtualized@^9.21.30": + version "9.21.30" + resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.30.tgz#ba39821bcb2487512a8a2cdd9fbdb5e6fc87fedb" + integrity sha512-4l2TFLQ8BCjNDQlvH85tU6gctuZoEdgYzENQyZHpgTHU7hoLzYgPSOALMAeA58LOWua8AzC6wBivPj1lfl6JgQ== dependencies: "@types/prop-types" "*" "@types/react" "*"