diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 72914507bb6a664..46fb853a7aa2993 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -31,6 +31,9 @@ export const RenderCellValue: React.FC< rowIndex, setCellProps, timelineId, + ecsData, + rowRenderers, + browserFields, }) => ( ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts index fc13680b81be2ba..1e6f613999ece33 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/column_renderer.ts @@ -6,7 +6,9 @@ */ import type React from 'react'; -import { ColumnHeaderOptions } from '../../../../../../common'; + +import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common'; +import { Ecs } from '../../../../../../common/ecs'; import { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline'; export interface ColumnRenderer { @@ -29,5 +31,8 @@ export interface ColumnRenderer { truncate?: boolean; values: string[] | null | undefined; linkValues?: string[] | null | undefined; + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; }) => React.ReactNode; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx index aeb40bed26c8ea9..3a7a43da2aedc6b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/constants.tsx @@ -17,3 +17,4 @@ export const EVENT_URL_FIELD_NAME = 'event.url'; export const SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name'; export const SIGNAL_STATUS_FIELD_NAME = 'signal.status'; export const AGENT_STATUS_FIELD_NAME = 'agent.status'; +export const REASON_FIELD_NAME = 'signal.reason'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts index 911dcc8cd2e8755..11c501f9426f40c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/index.ts @@ -16,6 +16,7 @@ import { unknownColumnRenderer } from './unknown_column_renderer'; import { zeekRowRenderer } from './zeek/zeek_row_renderer'; import { systemRowRenderers } from './system/generic_row_renderer'; import { threatMatchRowRenderer } from './cti/threat_match_row_renderer'; +import { reasonColumnRenderer } from './reason_column_renderer'; // The row renderers are order dependent and will return the first renderer // which returns true from its isInstance call. The bottom renderers which @@ -34,6 +35,7 @@ export const defaultRowRenderers: RowRenderer[] = [ ]; export const columnRenderers: ColumnRenderer[] = [ + reasonColumnRenderer, plainColumnRenderer, emptyColumnRenderer, unknownColumnRenderer, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx new file mode 100644 index 000000000000000..cc45b7cb2261d19 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.test.tsx @@ -0,0 +1,148 @@ +/* + * 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'; + +import { mockTimelineData } from '../../../../../common/mock'; +import { defaultColumnHeaderType } from '../column_headers/default_headers'; +import { REASON_FIELD_NAME } from './constants'; +import { reasonColumnRenderer } from './reason_column_renderer'; +import { plainColumnRenderer } from './plain_column_renderer'; + +import { + BrowserFields, + ColumnHeaderOptions, + RowRenderer, + RowRendererId, +} from '../../../../../../common'; +import { fireEvent, render } from '@testing-library/react'; +import { TestProviders } from '../../../../../../../timelines/public/mock'; +import { useDraggableKeyboardWrapper as mockUseDraggableKeyboardWrapper } from '../../../../../../../timelines/public/components'; +import { cloneDeep } from 'lodash'; +jest.mock('./plain_column_renderer'); + +jest.mock('../../../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../../../common/lib/kibana'); + return { + ...originalModule, + useKibana: () => ({ + services: { + timelines: { + getUseDraggableKeyboardWrapper: () => mockUseDraggableKeyboardWrapper, + }, + }, + }), + }; +}); + +jest.mock('../../../../../common/components/link_to', () => { + const original = jest.requireActual('../../../../../common/components/link_to'); + return { + ...original, + useFormatUrl: () => ({ + formatUrl: () => '', + }), + }; +}); + +const invalidEcs = cloneDeep(mockTimelineData[0].ecs); +const validEcs = cloneDeep(mockTimelineData[28].ecs); + +const field: ColumnHeaderOptions = { + id: 'test-field-id', + columnHeaderType: defaultColumnHeaderType, +}; + +const rowRenderers: RowRenderer[] = [ + { + id: RowRendererId.alerts, + isInstance: (ecs) => ecs === validEcs, + // eslint-disable-next-line react/display-name + renderRow: () => , + }, +]; +const browserFields: BrowserFields = {}; + +const defaultProps = { + columnName: REASON_FIELD_NAME, + eventId: 'test-vent-id', + field, + timelineId: 'test-timeline-id', + values: ['test-value'], +}; + +describe('reasonColumnRenderer', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('isIntance', () => { + it('returns true when columnName is `signal.reason`', () => { + expect(reasonColumnRenderer.isInstance(REASON_FIELD_NAME, [])).toBeTruthy(); + }); + }); + + describe('renderColumn', () => { + it('calls `plainColumnRenderer.renderColumn` when ecsData, rowRenderers or browserFields is empty', () => { + reasonColumnRenderer.renderColumn(defaultProps); + + expect(plainColumnRenderer.renderColumn).toBeCalledTimes(1); + }); + + it("doesn't call `plainColumnRenderer.renderColumn` when ecsData, rowRenderers or browserFields fields are not empty", () => { + reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: invalidEcs, + rowRenderers, + browserFields, + }); + + expect(plainColumnRenderer.renderColumn).toBeCalledTimes(0); + }); + + it("doesn't render popover button when getRowRenderer doesn't find a rowRenderer", () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: invalidEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + expect(wrapper.queryByTestId('reson-cell-button')).not.toBeInTheDocument(); + }); + + it('render popover button when getRowRenderer finds a rowRenderer', () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: validEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + expect(wrapper.queryByTestId('reson-cell-button')).toBeInTheDocument(); + }); + + it('render rowRender inside a popover when reson field button is clicked', () => { + const renderedColumn = reasonColumnRenderer.renderColumn({ + ...defaultProps, + ecsData: validEcs, + rowRenderers, + browserFields, + }); + + const wrapper = render({renderedColumn}); + + fireEvent.click(wrapper.getByTestId('reson-cell-button')); + + expect(wrapper.queryByTestId('test-row-render')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx new file mode 100644 index 000000000000000..24381f1a2afd0b8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/renderers/reason_column_renderer.tsx @@ -0,0 +1,153 @@ +/* + * 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 { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +import { isEqual } from 'lodash/fp'; +import React, { useMemo, useState } from 'react'; + +import styled from 'styled-components'; +import { BrowserFields, ColumnHeaderOptions, RowRenderer } from '../../../../../../common'; +import { Ecs } from '../../../../../../common/ecs'; +import { DefaultDraggable } from '../../../../../common/components/draggables'; +import { ColumnRenderer } from './column_renderer'; +import { REASON_FIELD_NAME } from './constants'; +import { getRowRenderer } from './get_row_renderer'; +import { plainColumnRenderer } from './plain_column_renderer'; + +export const reasonColumnRenderer: ColumnRenderer = { + isInstance: isEqual(REASON_FIELD_NAME), + + renderColumn: ({ + columnName, + eventId, + field, + isDraggable = true, + timelineId, + truncate, + values, + linkValues, + ecsData, + rowRenderers = [], + browserFields, + }: { + columnName: string; + eventId: string; + field: ColumnHeaderOptions; + isDraggable?: boolean; + timelineId: string; + truncate?: boolean; + values: string[] | undefined | null; + linkValues?: string[] | null | undefined; + + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; + }) => + values != null && ecsData && rowRenderers?.length > 0 && browserFields + ? values.map((value, i) => ( + + )) + : plainColumnRenderer.renderColumn({ + columnName, + eventId, + field, + isDraggable, + timelineId, + truncate, + values, + linkValues, + }), +}; + +const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` + font-weight: ${(props) => props.theme.eui.euiFontWeightRegular}; +`; + +const ReasonCell: React.FC<{ + contextId: string; + eventId: string; + fieldName: string; + isDraggable?: boolean; + value: string | number | undefined | null; + timelineId: string; + ecsData: Ecs; + rowRenderers: RowRenderer[]; + browserFields: BrowserFields; +}> = ({ + ecsData, + rowRenderers, + browserFields, + timelineId, + value, + fieldName, + isDraggable, + contextId, + eventId, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const rowRenderer = useMemo(() => getRowRenderer(ecsData, rowRenderers), [ecsData, rowRenderers]); + + const rowRender = + rowRenderer && + rowRenderer.renderRow({ + browserFields, + data: ecsData, + isDraggable: true, + timelineId, + }); + + const button = useMemo( + () => ( + setIsOpen(!isOpen)} + > + {value} + + ), + [setIsOpen, isOpen, value] + ); + + return ( + <> + + {rowRender ? ( + setIsOpen(false)} + button={button} + > + {rowRender} + + ) : ( + value + )} + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx index d2652ed063fc7ce..d45c8103d1cca5a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/cell_rendering/default_cell_renderer.tsx @@ -22,6 +22,9 @@ export const DefaultCellRenderer: React.FC = ({ linkValues, setCellProps, timelineId, + rowRenderers, + browserFields, + ecsData, }) => ( <> {getColumnRenderer(header.id, columnRenderers, data).renderColumn({ @@ -36,6 +39,9 @@ export const DefaultCellRenderer: React.FC = ({ data, fieldName: header.id, }), + rowRenderers, + browserFields, + ecsData, })} ); diff --git a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts index 2a6e1b3e12bcf7c..354a8b45e914d87 100644 --- a/x-pack/plugins/timelines/common/types/timeline/cells/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/cells/index.ts @@ -6,7 +6,9 @@ */ import { EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { TimelineNonEcsData } from '../../../search_strategy'; +import { RowRenderer } from '../../..'; +import { Ecs } from '../../../ecs'; +import { BrowserFields, TimelineNonEcsData } from '../../../search_strategy'; import { ColumnHeaderOptions } from '../columns'; /** The following props are provided to the function called by `renderCellValue` */ @@ -19,4 +21,7 @@ export type CellValueElementProps = EuiDataGridCellValueElementProps & { timelineId: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any setFlyoutAlert?: (data: any) => void; + ecsData?: Ecs; + rowRenderers?: RowRenderer[]; + browserFields?: BrowserFields; }; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index cc94f901446a70f..5fba7cff55e5c37 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -526,9 +526,12 @@ export const BodyComponent = React.memo( rowIndex, setCellProps, timelineId: tabType != null ? `${id}-${tabType}` : id, + ecsData: data[rowIndex].ecs, + browserFields, + rowRenderers, }); }, - [columnHeaders, data, id, renderCellValue, tabType, theme] + [columnHeaders, data, id, renderCellValue, tabType, theme, browserFields, rowRenderers] ); return (