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 (