,
"displayAsText": "a",
"id": "a",
+ "visibleCellActions": 5,
},
Object {
"actions": Object {
@@ -987,7 +997,7 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he
"label": "Sort descending",
},
},
- "cellActions": undefined,
+ "cellActions": Array [],
"display":
,
"displayAsText": "b",
"id": "b",
+ "visibleCellActions": 5,
},
Object {
"actions": Object {
@@ -1036,7 +1047,7 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he
"label": "Sort descending",
},
},
- "cellActions": undefined,
+ "cellActions": Array [],
"display":
,
"displayAsText": "c",
"id": "c",
+ "visibleCellActions": 5,
},
]
}
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx
new file mode 100644
index 0000000000000..1b4259e4cf63b
--- /dev/null
+++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.test.tsx
@@ -0,0 +1,167 @@
+/*
+ * 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 { EuiDataGridColumnCellAction, EuiDataGridColumnCellActionProps } from '@elastic/eui';
+import type { Datatable } from '@kbn/expressions-plugin/public';
+import { shallow } from 'enzyme';
+import { ReactNode } from 'react';
+import { FormatFactory } from '../../../../common/types';
+import type { LensCellValueAction } from '../../../types';
+import { createGridColumns } from './columns';
+
+const table: Datatable = {
+ type: 'datatable',
+ columns: [
+ {
+ id: 'a',
+ name: 'a',
+ meta: {
+ type: 'number',
+ },
+ },
+ ],
+ rows: [{ a: 123 }],
+};
+const visibleColumns = ['a'];
+const cellValueAction: LensCellValueAction = {
+ displayName: 'Test',
+ id: 'test',
+ iconType: 'test-icon',
+ execute: () => {},
+};
+
+type CreateGridColumnsParams = Parameters
;
+const callCreateGridColumns = (
+ params: Partial<{
+ bucketColumns: CreateGridColumnsParams[0];
+ table: CreateGridColumnsParams[1];
+ handleFilterClick: CreateGridColumnsParams[2];
+ handleTransposedColumnClick: CreateGridColumnsParams[3];
+ isReadOnly: CreateGridColumnsParams[4];
+ columnConfig: CreateGridColumnsParams[5];
+ visibleColumns: CreateGridColumnsParams[6];
+ formatFactory: CreateGridColumnsParams[7];
+ onColumnResize: CreateGridColumnsParams[8];
+ onColumnHide: CreateGridColumnsParams[9];
+ alignments: CreateGridColumnsParams[10];
+ headerRowHeight: CreateGridColumnsParams[11];
+ headerRowLines: CreateGridColumnsParams[12];
+ columnCellValueActions: CreateGridColumnsParams[13];
+ closeCellPopover: CreateGridColumnsParams[14];
+ columnFilterable: CreateGridColumnsParams[15];
+ }> = {}
+) =>
+ createGridColumns(
+ params.bucketColumns ?? [],
+ params.table ?? table,
+ params.handleFilterClick,
+ params.handleTransposedColumnClick,
+ params.isReadOnly ?? false,
+ params.columnConfig ?? { columns: [], sortingColumnId: undefined, sortingDirection: 'none' },
+ params.visibleColumns ?? visibleColumns,
+ params.formatFactory ?? (((x: unknown) => ({ convert: () => x })) as unknown as FormatFactory),
+ params.onColumnResize ?? jest.fn(),
+ params.onColumnHide ?? jest.fn(),
+ params.alignments ?? {},
+ params.headerRowHeight ?? 'auto',
+ params.headerRowLines ?? 1,
+ params.columnCellValueActions ?? [],
+ params.closeCellPopover ?? jest.fn(),
+ params.columnFilterable ?? []
+ );
+
+const renderCellAction = (
+ cellActions: EuiDataGridColumnCellAction[] | undefined,
+ index: number
+) => {
+ if (!cellActions?.[index]) {
+ return null;
+ }
+ const cellAction = (cellActions[index] as (props: EuiDataGridColumnCellActionProps) => ReactNode)(
+ {
+ rowIndex: 0,
+ columnId: 'a',
+ Component: () => <>>,
+ } as unknown as EuiDataGridColumnCellActionProps
+ );
+ return shallow({cellAction}
);
+};
+
+describe('getContentData', () => {
+ describe('cellActions', () => {
+ it('should include filter actions', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [true],
+ });
+ expect(cellActions).toHaveLength(2);
+ });
+
+ it('should not include filter actions if column not filterable', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [false],
+ });
+ expect(cellActions).toHaveLength(0);
+ });
+
+ it('should not include filter actions if no filter handler defined', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ columnFilterable: [true],
+ });
+ expect(cellActions).toHaveLength(0);
+ });
+
+ it('should include cell value actions', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ columnCellValueActions: [[cellValueAction]],
+ });
+ expect(cellActions).toHaveLength(1);
+ });
+
+ it('should include all actions', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [true],
+ columnCellValueActions: [[cellValueAction]],
+ });
+ expect(cellActions).toHaveLength(3);
+ });
+
+ it('should render filterFor as first action', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [true],
+ columnCellValueActions: [[cellValueAction]],
+ });
+ const wrapper = renderCellAction(cellActions, 0);
+ expect(wrapper?.find('Component').prop('data-test-subj')).toEqual('lensDatatableFilterFor');
+ });
+
+ it('should render filterOut as second action', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [true],
+ columnCellValueActions: [[cellValueAction]],
+ });
+ const wrapper = renderCellAction(cellActions, 1);
+ expect(wrapper?.find('Component').prop('data-test-subj')).toEqual('lensDatatableFilterOut');
+ });
+
+ it('should render cellValue actions at the end', () => {
+ const [{ cellActions }] = callCreateGridColumns({
+ handleFilterClick: () => {},
+ columnFilterable: [true],
+ columnCellValueActions: [[cellValueAction]],
+ });
+ const wrapper = renderCellAction(cellActions, 2);
+ expect(wrapper?.find('Component').prop('data-test-subj')).toEqual(
+ 'lensDatatableCellAction-test'
+ );
+ });
+ });
+});
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx
index faf2e9d5d826d..f35de7cb83a2e 100644
--- a/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx
+++ b/x-pack/plugins/lens/public/visualizations/datatable/components/columns.tsx
@@ -20,6 +20,7 @@ import type {
} from '@kbn/expressions-plugin/common';
import type { FormatFactory } from '../../../../common';
import type { ColumnConfig } from '../../../../common/expressions';
+import { LensCellValueAction } from '../../../types';
export const createGridColumns = (
bucketColumns: string[],
@@ -48,6 +49,7 @@ export const createGridColumns = (
alignments: Record,
headerRowHeight: 'auto' | 'single' | 'custom',
headerRowLines: number,
+ columnCellValueActions: LensCellValueAction[][] | undefined,
closeCellPopover?: Function,
columnFilterable?: boolean[]
) => {
@@ -77,87 +79,115 @@ export const createGridColumns = (
const columnArgs = columnConfig.columns.find(({ columnId }) => columnId === field);
- const cellActions =
- filterable && handleFilterClick && !columnArgs?.oneClickFilter
- ? [
- ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
- const { rowValue, contentsIsDefined, cellContent } = getContentData({
- rowIndex,
- columnId,
- });
+ const cellActions = [];
+ if (filterable && handleFilterClick && !columnArgs?.oneClickFilter) {
+ cellActions.push(
+ ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
+ const { rowValue, contentsIsDefined, cellContent } = getContentData({
+ rowIndex,
+ columnId,
+ });
- const filterForText = i18n.translate(
- 'xpack.lens.table.tableCellFilter.filterForValueText',
- {
- defaultMessage: 'Filter for value',
- }
- );
- const filterForAriaLabel = i18n.translate(
- 'xpack.lens.table.tableCellFilter.filterForValueAriaLabel',
- {
- defaultMessage: 'Filter for value: {cellContent}',
- values: {
- cellContent,
- },
- }
- );
+ const filterForText = i18n.translate(
+ 'xpack.lens.table.tableCellFilter.filterForValueText',
+ {
+ defaultMessage: 'Filter for value',
+ }
+ );
+ const filterForAriaLabel = i18n.translate(
+ 'xpack.lens.table.tableCellFilter.filterForValueAriaLabel',
+ {
+ defaultMessage: 'Filter for value: {cellContent}',
+ values: {
+ cellContent,
+ },
+ }
+ );
- return (
- contentsIsDefined && (
- {
- handleFilterClick(field, rowValue, colIndex, rowIndex);
- closeCellPopover?.();
- }}
- iconType="plusInCircle"
- >
- {filterForText}
-
- )
- );
- },
- ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
- const { rowValue, contentsIsDefined, cellContent } = getContentData({
- rowIndex,
- columnId,
- });
+ return (
+ contentsIsDefined && (
+ {
+ handleFilterClick(field, rowValue, colIndex, rowIndex);
+ closeCellPopover?.();
+ }}
+ iconType="plusInCircle"
+ >
+ {filterForText}
+
+ )
+ );
+ },
+ ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
+ const { rowValue, contentsIsDefined, cellContent } = getContentData({
+ rowIndex,
+ columnId,
+ });
+
+ const filterOutText = i18n.translate(
+ 'xpack.lens.table.tableCellFilter.filterOutValueText',
+ {
+ defaultMessage: 'Filter out value',
+ }
+ );
+ const filterOutAriaLabel = i18n.translate(
+ 'xpack.lens.table.tableCellFilter.filterOutValueAriaLabel',
+ {
+ defaultMessage: 'Filter out value: {cellContent}',
+ values: {
+ cellContent,
+ },
+ }
+ );
- const filterOutText = i18n.translate(
- 'xpack.lens.table.tableCellFilter.filterOutValueText',
- {
- defaultMessage: 'Filter out value',
- }
- );
- const filterOutAriaLabel = i18n.translate(
- 'xpack.lens.table.tableCellFilter.filterOutValueAriaLabel',
- {
- defaultMessage: 'Filter out value: {cellContent}',
- values: {
- cellContent,
- },
- }
- );
+ return (
+ contentsIsDefined && (
+ {
+ handleFilterClick(field, rowValue, colIndex, rowIndex, true);
+ closeCellPopover?.();
+ }}
+ iconType="minusInCircle"
+ >
+ {filterOutText}
+
+ )
+ );
+ }
+ );
+ }
- return (
- contentsIsDefined && (
- {
- handleFilterClick(field, rowValue, colIndex, rowIndex, true);
- closeCellPopover?.();
- }}
- iconType="minusInCircle"
- >
- {filterOutText}
-
- )
- );
- },
- ]
- : undefined;
+ // Add all the column compatible cell actions
+ const compatibleCellActions = columnCellValueActions?.[colIndex] ?? [];
+ compatibleCellActions.forEach((action) => {
+ cellActions.push(({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => {
+ const rowValue = table.rows[rowIndex][columnId];
+ const columnMeta = columnsReverseLookup[columnId].meta;
+ const data = {
+ value: rowValue,
+ columnMeta,
+ };
+ return (
+ rowValue != null && (
+ {
+ action.execute([data]);
+ closeCellPopover?.();
+ }}
+ iconType={action.iconType}
+ >
+ {action.displayName}
+
+ )
+ );
+ });
+ });
const isTransposed = Boolean(columnArgs?.originalColumnId);
const initialWidth = columnArgs?.width;
@@ -235,6 +265,7 @@ export const createGridColumns = (
const columnDefinition: EuiDataGridColumn = {
id: field,
cellActions,
+ visibleCellActions: 5,
display: {name}
,
displayAsText: name,
actions: {
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx
index 4b8f8067e974e..29f28a03438bc 100644
--- a/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx
+++ b/x-pack/plugins/lens/public/visualizations/datatable/components/table_basic.tsx
@@ -202,6 +202,11 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
[firstTableRef, onClickValue, isInteractive]
);
+ const columnCellValueActions = useMemo(
+ () => (isInteractive ? props.columnCellValueActions : undefined),
+ [props.columnCellValueActions, isInteractive]
+ );
+
const handleTransposedColumnClick = useMemo(
() =>
isInteractive
@@ -310,6 +315,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
alignments,
headerRowHeight,
headerRowLines,
+ columnCellValueActions,
dataGridRef.current?.closeCellPopover,
props.columnFilterable
),
@@ -327,6 +333,7 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
alignments,
headerRowHeight,
headerRowLines,
+ columnCellValueActions,
props.columnFilterable,
]
);
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts
index ec2b687690dd9..97fe88d2e9b2f 100644
--- a/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts
+++ b/x-pack/plugins/lens/public/visualizations/datatable/components/types.ts
@@ -10,7 +10,11 @@ import type { PaletteRegistry } from '@kbn/coloring';
import { CustomPaletteState } from '@kbn/charts-plugin/public';
import type { IAggType } from '@kbn/data-plugin/public';
import type { Datatable, RenderMode } from '@kbn/expressions-plugin/common';
-import type { ILensInterpreterRenderHandlers, LensEditEvent } from '../../../types';
+import type {
+ ILensInterpreterRenderHandlers,
+ LensCellValueAction,
+ LensEditEvent,
+} from '../../../types';
import {
LENS_EDIT_SORT_ACTION,
LENS_EDIT_RESIZE_ACTION,
@@ -58,6 +62,11 @@ export type DatatableRenderProps = DatatableProps & {
* ROW_CLICK_TRIGGER actions attached to it, otherwise false.
*/
rowHasRowClickTriggerActions?: boolean[];
+ /**
+ * Array of LensCellValueAction to be rendered on each column by id
+ * uses CELL_VALUE_TRIGGER actions attached.
+ */
+ columnCellValueActions?: LensCellValueAction[][];
columnFilterable?: boolean[];
};
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/expression.test.tsx b/x-pack/plugins/lens/public/visualizations/datatable/expression.test.tsx
index eb3835f95b6aa..57e64982b4420 100644
--- a/x-pack/plugins/lens/public/visualizations/datatable/expression.test.tsx
+++ b/x-pack/plugins/lens/public/visualizations/datatable/expression.test.tsx
@@ -4,13 +4,20 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-
-import type { DatatableProps } from '../../../common/expressions';
import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks';
+import type { DatatableProps } from '../../../common/expressions';
import type { FormatFactory } from '../../../common';
import { getDatatable } from '../../../common/expressions';
+import { getColumnCellValueActions } from './expression';
import type { Datatable } from '@kbn/expressions-plugin/common';
+import { LensCellValueAction } from '../../types';
+const cellValueAction: LensCellValueAction = {
+ displayName: 'Test',
+ id: 'test',
+ iconType: 'test-icon',
+ execute: () => {},
+};
function sampleArgs() {
const indexPatternId = 'indexPatternId';
const data: Datatable = {
@@ -93,4 +100,26 @@ describe('datatable_expression', () => {
});
});
});
+
+ describe('getColumnCellValueActions', () => {
+ it('should return column cell value actions', async () => {
+ const config = sampleArgs();
+ const result = await getColumnCellValueActions(config, async () => [cellValueAction]);
+ expect(result).toEqual([[cellValueAction], [cellValueAction], [cellValueAction]]);
+ });
+
+ it('should return empty actions if no data passed', async () => {
+ const result = await getColumnCellValueActions(
+ { data: null } as unknown as DatatableProps,
+ async () => [cellValueAction]
+ );
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty actions if no getCompatibleCellValueActions handler passed', async () => {
+ const config = sampleArgs();
+ const result = await getColumnCellValueActions(config, undefined);
+ expect(result).toEqual([]);
+ });
+ });
});
diff --git a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx
index 32fb293647620..c8cd7da4c0bc3 100644
--- a/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx
+++ b/x-pack/plugins/lens/public/visualizations/datatable/expression.tsx
@@ -21,11 +21,15 @@ import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { trackUiCounterEvents } from '../../lens_ui_telemetry';
import { DatatableComponent } from './components/table_basic';
-import type { ILensInterpreterRenderHandlers } from '../../types';
+import type {
+ GetCompatibleCellValueActions,
+ ILensInterpreterRenderHandlers,
+ LensCellValueAction,
+} from '../../types';
import type { FormatFactory } from '../../../common';
import type { DatatableProps } from '../../../common/expressions';
-async function columnsFilterable(table: Datatable, handlers: IInterpreterRenderHandlers) {
+async function getColumnsFilterable(table: Datatable, handlers: IInterpreterRenderHandlers) {
if (!table.rows.length) {
return;
}
@@ -49,6 +53,27 @@ async function columnsFilterable(table: Datatable, handlers: IInterpreterRenderH
);
}
+/**
+ * Retrieves the compatible CELL_VALUE_TRIGGER actions indexed by column
+ **/
+export async function getColumnCellValueActions(
+ config: DatatableProps,
+ getCompatibleCellValueActions?: ILensInterpreterRenderHandlers['getCompatibleCellValueActions']
+): Promise {
+ if (!config.data || !getCompatibleCellValueActions) {
+ return [];
+ }
+ return Promise.all(
+ config.data.columns.map(({ meta: columnMeta }) => {
+ try {
+ return (getCompatibleCellValueActions as GetCompatibleCellValueActions)([{ columnMeta }]);
+ } catch {
+ return [];
+ }
+ })
+ );
+}
+
export const getDatatableRenderer = (dependencies: {
formatFactory: FormatFactory;
getType: Promise<(name: string) => IAggType>;
@@ -71,7 +96,7 @@ export const getDatatableRenderer = (dependencies: {
handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode));
const resolvedGetType = await dependencies.getType;
- const { hasCompatibleActions, isInteractive } = handlers;
+ const { hasCompatibleActions, isInteractive, getCompatibleCellValueActions } = handlers;
const renderComplete = () => {
trackUiCounterEvents('table', handlers.getExecutionContext());
@@ -104,6 +129,11 @@ export const getDatatableRenderer = (dependencies: {
}
}
+ const [columnCellValueActions, columnsFilterable] = await Promise.all([
+ getColumnCellValueActions(config, getCompatibleCellValueActions),
+ getColumnsFilterable(config.data, handlers),
+ ]);
+
ReactDOM.render(
@@ -115,7 +145,8 @@ export const getDatatableRenderer = (dependencies: {
paletteService={dependencies.paletteService}
getType={resolvedGetType}
rowHasRowClickTriggerActions={rowHasRowClickTriggerActions}
- columnFilterable={await columnsFilterable(config.data, handlers)}
+ columnCellValueActions={columnCellValueActions}
+ columnFilterable={columnsFilterable}
interactive={isInteractive()}
uiSettings={dependencies.uiSettings}
renderComplete={renderComplete}
diff --git a/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.test.ts b/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.test.ts
new file mode 100644
index 0000000000000..16e4b5566d058
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.test.ts
@@ -0,0 +1,227 @@
+/*
+ * 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 type { CellValueContext, EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public';
+import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+import { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
+import type { SecurityAppStore } from '../../common/store/types';
+import { AddToTimelineAction } from './add_to_timeline_action';
+import { KibanaServices } from '../../common/lib/kibana';
+import { APP_UI_ID } from '../../../common/constants';
+import { Subject } from 'rxjs';
+import { TimelineId } from '../../../common/types';
+import { addProvider, showTimeline } from '../../timelines/store/timeline/actions';
+
+jest.mock('../../common/lib/kibana');
+const currentAppId$ = new Subject();
+KibanaServices.get().application.currentAppId$ = currentAppId$.asObservable();
+const mockWarningToast = jest.fn();
+KibanaServices.get().notifications.toasts.addWarning = mockWarningToast;
+
+const mockDispatch = jest.fn();
+const store = {
+ dispatch: mockDispatch,
+} as unknown as SecurityAppStore;
+
+class MockEmbeddable {
+ public type;
+ constructor(type: string) {
+ this.type = type;
+ }
+ getFilters() {}
+ getQuery() {}
+}
+
+const lensEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE) as unknown as IEmbeddable;
+
+const columnMeta = {
+ field: 'user.name',
+ type: 'string' as const,
+ source: 'esaggs',
+ sourceParams: { indexPatternId: 'some-pattern-id' },
+};
+const value = 'the value';
+const eventId = 'event_1';
+const data: CellValueContext['data'] = [{ columnMeta, value, eventId }];
+
+describe('AddToTimelineAction', () => {
+ const addToTimelineAction = new AddToTimelineAction(store);
+
+ beforeEach(() => {
+ currentAppId$.next(APP_UI_ID);
+ jest.clearAllMocks();
+ });
+
+ it('should return display name', () => {
+ expect(addToTimelineAction.getDisplayName()).toEqual('Add to timeline');
+ });
+
+ it('should return icon type', () => {
+ expect(addToTimelineAction.getIconType()).toEqual('timeline');
+ });
+
+ describe('isCompatible', () => {
+ it('should return false if error embeddable', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: new ErrorEmbeddable('some error', {} as EmbeddableInput),
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not lens embeddable', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: new MockEmbeddable('not_lens') as unknown as IEmbeddable,
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data is empty', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data do not have column meta', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{}],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data column meta do not have field', async () => {
+ const { field, ...testColumnMeta } = columnMeta;
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data column meta field is blacklisted', async () => {
+ const testColumnMeta = { ...columnMeta, field: 'signal.reason' };
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data column meta field not filterable', async () => {
+ let testColumnMeta = { ...columnMeta, source: '' };
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+
+ testColumnMeta = { ...columnMeta, sourceParams: { indexPatternId: '' } };
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not in Security', async () => {
+ currentAppId$.next('not security');
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return true if everything is okay', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data,
+ })
+ ).toEqual(true);
+ });
+ });
+
+ describe('execute', () => {
+ it('should execute normally', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data,
+ });
+ expect(mockDispatch).toHaveBeenCalledTimes(2);
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: addProvider.type,
+ payload: {
+ id: TimelineId.active,
+ providers: [
+ {
+ and: [],
+ enabled: true,
+ excluded: false,
+ id: 'event-details-value-default-draggable-timeline-1-event_1-user_name-0-the value',
+ kqlQuery: '',
+ name: 'user.name',
+ queryMatch: {
+ field: 'user.name',
+ operator: ':',
+ value: 'the value',
+ },
+ },
+ ],
+ },
+ });
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: showTimeline.type,
+ payload: {
+ id: TimelineId.active,
+ show: true,
+ },
+ });
+ expect(mockWarningToast).not.toHaveBeenCalled();
+ });
+
+ it('should show warning if no provider added', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data: [],
+ });
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockWarningToast).toHaveBeenCalled();
+ });
+
+ it('should show warning if no value in the data', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta }],
+ });
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockWarningToast).toHaveBeenCalled();
+ });
+
+ it('should show warning if no field in the data column meta', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: { ...columnMeta, field: undefined }, value }],
+ });
+ expect(mockDispatch).not.toHaveBeenCalled();
+ expect(mockWarningToast).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.ts b/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.ts
new file mode 100644
index 0000000000000..b51809d56f0d4
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/add_to_timeline/add_to_timeline_action.ts
@@ -0,0 +1,107 @@
+/*
+ * 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 type { CellValueContext } from '@kbn/embeddable-plugin/public';
+import { isErrorEmbeddable, isFilterableEmbeddable } from '@kbn/embeddable-plugin/public';
+import { i18n } from '@kbn/i18n';
+import type { Action } from '@kbn/ui-actions-plugin/public';
+import { KibanaServices } from '../../common/lib/kibana';
+import type { SecurityAppStore } from '../../common/store/types';
+import { addProvider, showTimeline } from '../../timelines/store/timeline/actions';
+import type { DataProvider } from '../../../common/types';
+import { TimelineId } from '../../../common/types';
+import { createDataProviders } from './data_provider';
+import { fieldHasCellActions, isInSecurityApp, isLensEmbeddable } from '../utils';
+
+export const ACTION_ID = 'addToTimeline';
+
+function isDataColumnsFilterable(data?: CellValueContext['data']): boolean {
+ return (
+ !!data &&
+ data.length > 0 &&
+ data.every(
+ ({ columnMeta }) =>
+ columnMeta &&
+ fieldHasCellActions(columnMeta.field) &&
+ columnMeta.source === 'esaggs' &&
+ columnMeta.sourceParams?.indexPatternId
+ )
+ );
+}
+
+export class AddToTimelineAction implements Action {
+ public readonly type = ACTION_ID;
+ public readonly id = ACTION_ID;
+ public order = 1;
+
+ private icon = 'timeline';
+ private toastsService;
+ private store;
+ private currentAppId: string | undefined;
+
+ constructor(store: SecurityAppStore) {
+ this.store = store;
+ const { application, notifications } = KibanaServices.get();
+ this.toastsService = notifications.toasts;
+
+ application.currentAppId$.subscribe((currentAppId) => {
+ this.currentAppId = currentAppId;
+ });
+ }
+
+ public getDisplayName() {
+ return i18n.translate('xpack.securitySolution.actions.cellValue.addToTimeline.displayName', {
+ defaultMessage: 'Add to timeline',
+ });
+ }
+
+ public getIconType() {
+ return this.icon;
+ }
+
+ public async isCompatible({ embeddable, data }: CellValueContext) {
+ return (
+ !isErrorEmbeddable(embeddable) &&
+ isLensEmbeddable(embeddable) &&
+ isFilterableEmbeddable(embeddable) &&
+ isDataColumnsFilterable(data) &&
+ isInSecurityApp(this.currentAppId)
+ );
+ }
+
+ public async execute({ data }: CellValueContext) {
+ const dataProviders = data.reduce((acc, { columnMeta, value, eventId }) => {
+ const dataProvider = createDataProviders({
+ contextId: TimelineId.active,
+ fieldType: columnMeta?.type,
+ values: value,
+ field: columnMeta?.field,
+ eventId,
+ });
+ if (dataProvider) {
+ acc.push(...dataProvider);
+ }
+ return acc;
+ }, []);
+
+ if (dataProviders.length > 0) {
+ this.store.dispatch(addProvider({ id: TimelineId.active, providers: dataProviders }));
+ this.store.dispatch(showTimeline({ id: TimelineId.active, show: true }));
+ } else {
+ this.toastsService.addWarning({
+ title: i18n.translate(
+ 'xpack.securitySolution.actions.cellValue.addToTimeline.warningTitle',
+ { defaultMessage: 'Unable to add to timeline' }
+ ),
+ text: i18n.translate(
+ 'xpack.securitySolution.actions.cellValue.addToTimeline.warningMessage',
+ { defaultMessage: 'Filter received is empty or cannot be added to timeline' }
+ ),
+ });
+ }
+ }
+}
diff --git a/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts b/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts
new file mode 100644
index 0000000000000..e8c2430f29206
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts
@@ -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 { escapeDataProviderId } from '@kbn/securitysolution-t-grid';
+import { isArray, isString, isEmpty } from 'lodash/fp';
+import { INDICATOR_REFERENCE } from '../../../common/cti/constants';
+import type { DataProvider } from '../../../common/types';
+import { IS_OPERATOR } from '../../../common/types';
+import type { BrowserField } from '../../common/containers/source';
+import { IP_FIELD_TYPE } from '../../explore/network/components/ip';
+import { PORT_NAMES } from '../../explore/network/components/port/helpers';
+import { EVENT_DURATION_FIELD_NAME } from '../../timelines/components/duration';
+import { BYTES_FORMAT } from '../../timelines/components/timeline/body/renderers/bytes';
+import {
+ GEO_FIELD_TYPE,
+ MESSAGE_FIELD_NAME,
+ HOST_NAME_FIELD_NAME,
+ SIGNAL_RULE_NAME_FIELD_NAME,
+ EVENT_MODULE_FIELD_NAME,
+ SIGNAL_STATUS_FIELD_NAME,
+ AGENT_STATUS_FIELD_NAME,
+ RULE_REFERENCE_FIELD_NAME,
+ REFERENCE_URL_FIELD_NAME,
+ EVENT_URL_FIELD_NAME,
+} from '../../timelines/components/timeline/body/renderers/constants';
+
+export const getDataProvider = (field: string, id: string, value: string): DataProvider => ({
+ and: [],
+ enabled: true,
+ id: escapeDataProviderId(id),
+ name: field,
+ excluded: false,
+ kqlQuery: '',
+ queryMatch: {
+ field,
+ value,
+ operator: IS_OPERATOR,
+ },
+});
+
+export interface CreateDataProviderParams {
+ contextId?: string;
+ eventId?: string;
+ field?: string;
+ fieldFormat?: string;
+ fieldFromBrowserField?: BrowserField;
+ fieldType?: string;
+ isObjectArray?: boolean;
+ linkValue?: string | null;
+ values: string | string[] | null | undefined;
+}
+
+export const createDataProviders = ({
+ contextId,
+ eventId,
+ field,
+ fieldFormat,
+ fieldType,
+ linkValue,
+ values,
+}: CreateDataProviderParams) => {
+ if (field == null || values === null || values === undefined) return null;
+ const arrayValues = Array.isArray(values) ? values : [values];
+ return arrayValues.reduce((dataProviders, value, index) => {
+ let id: string = '';
+ const appendedUniqueId = `${contextId}-${eventId}-${field}-${index}-${value}`;
+
+ if (fieldType === GEO_FIELD_TYPE || field === MESSAGE_FIELD_NAME) {
+ return dataProviders;
+ } else if (fieldType === IP_FIELD_TYPE) {
+ id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`;
+ if (isString(value) && !isEmpty(value)) {
+ let addresses = value;
+ try {
+ addresses = JSON.parse(value);
+ } catch (_) {
+ // Default to keeping the existing string value
+ }
+ if (isArray(addresses)) {
+ addresses.forEach((ip) => dataProviders.push(getDataProvider(field, id, ip)));
+ } else {
+ dataProviders.push(getDataProvider(field, id, addresses));
+ }
+ return dataProviders;
+ }
+ } else if (PORT_NAMES.some((portName) => field === portName)) {
+ id = `port-default-draggable-${appendedUniqueId}`;
+ } else if (field === EVENT_DURATION_FIELD_NAME) {
+ id = `duration-default-draggable-${appendedUniqueId}`;
+ } else if (field === HOST_NAME_FIELD_NAME) {
+ id = `event-details-value-default-draggable-${appendedUniqueId}`;
+ } else if (fieldFormat === BYTES_FORMAT) {
+ id = `bytes-default-draggable-${appendedUniqueId}`;
+ } else if (field === SIGNAL_RULE_NAME_FIELD_NAME) {
+ id = `event-details-value-default-draggable-${appendedUniqueId}-${linkValue}`;
+ } else if (field === EVENT_MODULE_FIELD_NAME) {
+ id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`;
+ } else if (field === SIGNAL_STATUS_FIELD_NAME) {
+ id = `alert-details-value-default-draggable-${appendedUniqueId}`;
+ } else if (field === AGENT_STATUS_FIELD_NAME) {
+ id = `event-details-value-default-draggable-${appendedUniqueId}`;
+ } else if (
+ [
+ RULE_REFERENCE_FIELD_NAME,
+ REFERENCE_URL_FIELD_NAME,
+ EVENT_URL_FIELD_NAME,
+ INDICATOR_REFERENCE,
+ ].includes(field)
+ ) {
+ id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`;
+ } else {
+ id = `event-details-value-default-draggable-${appendedUniqueId}`;
+ }
+ dataProviders.push(getDataProvider(field, id, value));
+ return dataProviders;
+ }, []);
+};
diff --git a/x-pack/plugins/security_solution/public/actions/add_to_timeline/index.ts b/x-pack/plugins/security_solution/public/actions/add_to_timeline/index.ts
new file mode 100644
index 0000000000000..59df42bfc3fc5
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/add_to_timeline/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { AddToTimelineAction } from './add_to_timeline_action';
diff --git a/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.test.ts b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.test.ts
new file mode 100644
index 0000000000000..d8636a1e03b55
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.test.ts
@@ -0,0 +1,170 @@
+/*
+ * 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 type { CellValueContext, EmbeddableInput, IEmbeddable } from '@kbn/embeddable-plugin/public';
+import { ErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+import { LENS_EMBEDDABLE_TYPE } from '@kbn/lens-plugin/public';
+import { CopyToClipboardAction } from './copy_to_clipboard_action';
+import { KibanaServices } from '../../common/lib/kibana';
+import { APP_UI_ID } from '../../../common/constants';
+import { Subject } from 'rxjs';
+
+jest.mock('../../common/lib/kibana');
+const currentAppId$ = new Subject();
+KibanaServices.get().application.currentAppId$ = currentAppId$.asObservable();
+const mockSuccessToast = jest.fn();
+KibanaServices.get().notifications.toasts.addSuccess = mockSuccessToast;
+
+const mockCopy = jest.fn((text: string) => true);
+jest.mock('copy-to-clipboard', () => (text: string) => mockCopy(text));
+
+class MockEmbeddable {
+ public type;
+ constructor(type: string) {
+ this.type = type;
+ }
+ getFilters() {}
+ getQuery() {}
+}
+
+const lensEmbeddable = new MockEmbeddable(LENS_EMBEDDABLE_TYPE) as unknown as IEmbeddable;
+
+const columnMeta = {
+ field: 'user.name',
+ type: 'string' as const,
+ source: 'esaggs',
+ sourceParams: { indexPatternId: 'some-pattern-id' },
+};
+const data: CellValueContext['data'] = [{ columnMeta, value: 'the value' }];
+
+describe('CopyToClipboardAction', () => {
+ const addToTimelineAction = new CopyToClipboardAction();
+
+ beforeEach(() => {
+ currentAppId$.next(APP_UI_ID);
+ jest.clearAllMocks();
+ });
+
+ it('should return display name', () => {
+ expect(addToTimelineAction.getDisplayName()).toEqual('Copy to clipboard');
+ });
+
+ it('should return icon type', () => {
+ expect(addToTimelineAction.getIconType()).toEqual('copyClipboard');
+ });
+
+ describe('isCompatible', () => {
+ it('should return false if error embeddable', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: new ErrorEmbeddable('some error', {} as EmbeddableInput),
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not lens embeddable', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: new MockEmbeddable('not_lens') as unknown as IEmbeddable,
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data is empty', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data do not have column meta', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{}],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data column meta do not have field', async () => {
+ const { field, ...testColumnMeta } = columnMeta;
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if data column meta field is blacklisted', async () => {
+ const testColumnMeta = { ...columnMeta, field: 'signal.reason' };
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data: [{ columnMeta: testColumnMeta }],
+ })
+ ).toEqual(false);
+ });
+
+ it('should return false if not in Security', async () => {
+ currentAppId$.next('not security');
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data,
+ })
+ ).toEqual(false);
+ });
+
+ it('should return true if everything is okay', async () => {
+ expect(
+ await addToTimelineAction.isCompatible({
+ embeddable: lensEmbeddable,
+ data,
+ })
+ ).toEqual(true);
+ });
+ });
+
+ describe('execute', () => {
+ it('should execute normally', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data,
+ });
+ expect(mockCopy).toHaveBeenCalledWith('user.name: "the value"');
+ expect(mockSuccessToast).toHaveBeenCalled();
+ });
+
+ it('should execute with multiple values', async () => {
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data: [
+ ...data,
+ { columnMeta: { ...columnMeta, field: 'host.name' }, value: 'host name value' },
+ ],
+ });
+ expect(mockCopy).toHaveBeenCalledWith(
+ 'user.name: "the value" | host.name: "host name value"'
+ );
+ expect(mockSuccessToast).toHaveBeenCalled();
+ });
+
+ it('should show warning if no provider added', async () => {
+ mockCopy.mockReturnValue(false);
+ await addToTimelineAction.execute({
+ embeddable: lensEmbeddable,
+ data: [],
+ });
+ expect(mockSuccessToast).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.ts b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.ts
new file mode 100644
index 0000000000000..45e975406be02
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/copy_to_clipboard_action.ts
@@ -0,0 +1,81 @@
+/*
+ * 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 type { CellValueContext } from '@kbn/embeddable-plugin/public';
+import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public';
+import { i18n } from '@kbn/i18n';
+import type { Action } from '@kbn/ui-actions-plugin/public';
+import copy from 'copy-to-clipboard';
+import { KibanaServices } from '../../common/lib/kibana';
+import { fieldHasCellActions, isInSecurityApp, isLensEmbeddable } from '../utils';
+
+export const ACTION_ID = 'copyToClipboard';
+
+function isDataColumnsValid(data?: CellValueContext['data']): boolean {
+ return (
+ !!data &&
+ data.length > 0 &&
+ data.every(({ columnMeta }) => columnMeta && fieldHasCellActions(columnMeta.field))
+ );
+}
+
+export class CopyToClipboardAction implements Action {
+ public readonly type = ACTION_ID;
+ public readonly id = ACTION_ID;
+ public order = 2;
+
+ private icon = 'copyClipboard';
+
+ private toastsService;
+ private currentAppId: string | undefined;
+
+ constructor() {
+ const { application, notifications } = KibanaServices.get();
+ this.toastsService = notifications.toasts;
+
+ application.currentAppId$.subscribe((currentAppId) => {
+ this.currentAppId = currentAppId;
+ });
+ }
+
+ public getDisplayName() {
+ return i18n.translate('xpack.securitySolution.actions.cellValue.copyToClipboard.displayName', {
+ defaultMessage: 'Copy to clipboard',
+ });
+ }
+
+ public getIconType() {
+ return this.icon;
+ }
+
+ public async isCompatible({ embeddable, data }: CellValueContext) {
+ return (
+ !isErrorEmbeddable(embeddable) &&
+ isLensEmbeddable(embeddable) &&
+ isDataColumnsValid(data) &&
+ isInSecurityApp(this.currentAppId)
+ );
+ }
+
+ public async execute({ data }: CellValueContext) {
+ const text = data
+ .map(({ columnMeta, value }) => `${columnMeta?.field}${value != null ? `: "${value}"` : ''}`)
+ .join(' | ');
+
+ const isSuccess = copy(text, { debug: true });
+
+ if (isSuccess) {
+ this.toastsService.addSuccess({
+ title: i18n.translate(
+ 'xpack.securitySolution.actions.cellValue.copyToClipboard.successMessage',
+ { defaultMessage: 'Copied to the clipboard' }
+ ),
+ toastLifeTimeMs: 800,
+ });
+ }
+ }
+}
diff --git a/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/index.ts b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/index.ts
new file mode 100644
index 0000000000000..d9e1ddf1af815
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/copy_to_clipboard/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { CopyToClipboardAction } from './copy_to_clipboard_action';
diff --git a/x-pack/plugins/security_solution/public/actions/index.ts b/x-pack/plugins/security_solution/public/actions/index.ts
new file mode 100644
index 0000000000000..25c02feb0276c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/index.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+export { registerActions } from './register';
diff --git a/x-pack/plugins/security_solution/public/actions/jest.config.js b/x-pack/plugins/security_solution/public/actions/jest.config.js
new file mode 100644
index 0000000000000..0c09f187ec983
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/jest.config.js
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+module.exports = {
+ preset: '@kbn/test',
+ rootDir: '../../../../..',
+ roots: ['/x-pack/plugins/security_solution/public/actions'],
+ coverageDirectory:
+ '/target/kibana-coverage/jest/x-pack/plugins/security_solution/public/actions',
+ coverageReporters: ['text', 'html'],
+ collectCoverageFrom: ['/x-pack/plugins/security_solution/public/actions/**/*.{ts,tsx}'],
+ // See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core.
+ moduleNameMapper: {
+ 'core/server$': '/x-pack/plugins/security_solution/server/__mocks__/core.mock.ts',
+ 'task_manager/server$':
+ '/x-pack/plugins/security_solution/server/__mocks__/task_manager.mock.ts',
+ 'alerting/server$': '/x-pack/plugins/security_solution/server/__mocks__/alert.mock.ts',
+ 'actions/server$': '/x-pack/plugins/security_solution/server/__mocks__/action.mock.ts',
+ },
+};
diff --git a/x-pack/plugins/security_solution/public/actions/register.ts b/x-pack/plugins/security_solution/public/actions/register.ts
new file mode 100644
index 0000000000000..8024b021eac4e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/register.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 { CELL_VALUE_TRIGGER } from '@kbn/embeddable-plugin/public';
+import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
+import type { SecurityAppStore } from '../common/store/types';
+import { AddToTimelineAction } from './add_to_timeline';
+import { CopyToClipboardAction } from './copy_to_clipboard';
+
+export const registerActions = (uiActions: UiActionsStart, store: SecurityAppStore) => {
+ const addToTimelineAction = new AddToTimelineAction(store);
+ uiActions.addTriggerAction(CELL_VALUE_TRIGGER, addToTimelineAction);
+
+ const copyToClipboardAction = new CopyToClipboardAction();
+ uiActions.addTriggerAction(CELL_VALUE_TRIGGER, copyToClipboardAction);
+};
diff --git a/x-pack/plugins/security_solution/public/actions/utils.ts b/x-pack/plugins/security_solution/public/actions/utils.ts
new file mode 100644
index 0000000000000..1753cd607554e
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/actions/utils.ts
@@ -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 type { IEmbeddable } from '@kbn/embeddable-plugin/public';
+import { LENS_EMBEDDABLE_TYPE, type Embeddable as LensEmbeddable } from '@kbn/lens-plugin/public';
+import { APP_UI_ID } from '../../common/constants';
+import { FIELDS_WITHOUT_CELL_ACTIONS } from '../common/lib/cell_actions/constants';
+
+export const isInSecurityApp = (currentAppId?: string): boolean => {
+ return !!currentAppId && currentAppId === APP_UI_ID;
+};
+
+export const isLensEmbeddable = (embeddable: IEmbeddable): embeddable is LensEmbeddable => {
+ return embeddable.type === LENS_EMBEDDABLE_TYPE;
+};
+
+export const fieldHasCellActions = (field?: string): boolean => {
+ return !!field && !FIELDS_WITHOUT_CELL_ACTIONS.includes(field);
+};
diff --git a/x-pack/plugins/security_solution/public/common/components/page/index.tsx b/x-pack/plugins/security_solution/public/common/components/page/index.tsx
index 44884c0b15198..aa59b180f5817 100644
--- a/x-pack/plugins/security_solution/public/common/components/page/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/page/index.tsx
@@ -78,23 +78,10 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
${TIMELINE_OVERRIDES_CSS_STYLESHEET}
- .euiDataGridRowCell .euiDataGridRowCell__expandActions .euiDataGridRowCell__actionButtonIcon {
- display: none;
-
- &:first-child,
- &:nth-child(2),
- &:nth-child(3),
- &:last-child {
- display: inline-flex;
- }
-
- }
-
/*
overrides the default styling of EuiDataGrid expand popover footer to
make it a column of actions instead of the default actions row
*/
-
.euiDataGridRowCell__popover {
max-width: 815px !important;
@@ -109,13 +96,11 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
}
}
-
&.euiPopover__panel[data-popover-open] {
padding: 8px 0;
min-width: 65px;
}
-
.euiPopoverFooter {
border: 0;
margin-top: 0 !important;
@@ -123,14 +108,6 @@ export const AppGlobalStyle = createGlobalStyle<{ theme: { eui: { euiColorPrimar
flex-direction: column;
}
}
-
- // Hide EUI's 'Filter in' and 'Filter out' footer buttons - replaced with our own buttons
- .euiPopoverFooter:nth-child(2) {
- .euiFlexItem:first-child,
- .euiFlexItem:nth-child(2) {
- display: none;
- }
- }
}
/* overrides default styling in angular code that was not theme-friendly */
diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx
index b15a649f15cac..6a17fe6df79b6 100644
--- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.test.tsx
@@ -64,6 +64,7 @@ describe('default cell actions', () => {
initialWidth: 105,
},
]);
+
describe.each(columnHeadersToTest)('columns with a link action', (columnHeaders) => {
test(`${columnHeaders.id ?? columnHeaders.type}`, () => {
const columnsWithCellActions: EuiDataGridColumn[] = [columnHeaders].map((header) => {
diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx
index b565872659f12..e20c4887c0df9 100644
--- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.test.tsx
@@ -30,12 +30,4 @@ describe('ExpandedCellValueActions', () => {
test('renders show topN button', () => {
expect(wrapper.find('[data-test-subj="data-grid-expanded-show-top-n"]').exists()).toBeTruthy();
});
-
- test('renders filter in button', () => {
- expect(wrapper.find('EuiFlexItem').first().html()).toContain('Filter button');
- });
-
- test('renders filter out button', () => {
- expect(wrapper.find('EuiFlexItem').last().html()).toContain('Filter out button');
- });
});
diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx
index 4711c6d8b68e0..4d36c450fd177 100644
--- a/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx
+++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/expanded_cell_value_actions.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
+import { EuiButtonEmpty } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { useMemo, useState, useCallback } from 'react';
import styled from 'styled-components';
@@ -13,7 +13,6 @@ import type { Filter } from '@kbn/es-query';
import type { ColumnHeaderOptions } from '../../../../common/types';
import { allowTopN } from '../../components/drag_and_drop/helpers';
import { ShowTopNButton } from '../../components/hover_actions/actions/show_top_n';
-import { useKibana } from '../kibana';
import { SHOW_TOP_VALUES, HIDE_TOP_VALUES } from './translations';
interface Props {
@@ -22,16 +21,10 @@ interface Props {
scopeId: string;
value: string[] | undefined;
onFilterAdded?: () => void;
- closeCellPopover?: () => void;
}
-const StyledFlexGroup = styled(EuiFlexGroup)`
- border-top: 1px solid #d3dae6;
+const StyledContent = styled.div<{ $isDetails: boolean }>`
border-bottom: 1px solid #d3dae6;
- margin-top: 2px;
-`;
-
-export const StyledContent = styled.div<{ $isDetails: boolean }>`
padding: ${({ $isDetails }) => ($isDetails ? '0 8px' : undefined)};
`;
@@ -41,14 +34,7 @@ const ExpandedCellValueActionsComponent: React.FC = ({
onFilterAdded,
scopeId,
value,
- closeCellPopover,
}) => {
- const {
- timelines,
- data: {
- query: { filterManager },
- },
- } = useKibana().services;
const showButton = useMemo(
() =>
allowTopN({
@@ -90,34 +76,6 @@ const ExpandedCellValueActionsComponent: React.FC = ({
/>
) : null}
-
-
- {timelines.getHoverActions().getFilterForValueButton({
- Component: EuiButtonEmpty,
- field: field.id,
- filterManager,
- onFilterAdded,
- ownFocus: false,
- size: 's',
- showTooltip: false,
- value,
- onClick: closeCellPopover,
- })}
-
-
- {timelines.getHoverActions().getFilterOutValueButton({
- Component: EuiButtonEmpty,
- field: field.id,
- filterManager,
- onFilterAdded,
- ownFocus: false,
- size: 's',
- showTooltip: false,
- value,
- onClick: closeCellPopover,
- })}
-
-
>
);
};
diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts
index dc790b99c13d1..f7d94a994b919 100644
--- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts
+++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts
@@ -23,9 +23,17 @@ import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
const mockStartServicesMock = createStartServicesMock();
export const KibanaServices = {
get: jest.fn(() => {
- const { http, uiSettings, notifications, data, unifiedSearch } = mockStartServicesMock;
+ const { application, http, uiSettings, notifications, data, unifiedSearch } =
+ mockStartServicesMock;
- return { http, uiSettings, notifications, data, unifiedSearch };
+ return {
+ application,
+ http,
+ uiSettings,
+ notifications,
+ data,
+ unifiedSearch,
+ };
}),
getKibanaVersion: jest.fn(() => '8.0.0'),
};
diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts
index 48ed3d8889fca..a4db8f5c919ba 100644
--- a/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts
+++ b/x-pack/plugins/security_solution/public/common/lib/kibana/services.ts
@@ -8,7 +8,7 @@
import type { CoreStart } from '@kbn/core/public';
import type { StartPlugins } from '../../../types';
-type GlobalServices = Pick &
+type GlobalServices = Pick &
Pick;
export class KibanaServices {
@@ -17,13 +17,14 @@ export class KibanaServices {
public static init({
http,
+ application,
data,
unifiedSearch,
kibanaVersion,
uiSettings,
notifications,
}: GlobalServices & { kibanaVersion: string }) {
- this.services = { data, http, uiSettings, unifiedSearch, notifications };
+ this.services = { application, data, http, uiSettings, unifiedSearch, notifications };
this.kibanaVersion = kibanaVersion;
}
diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts
index a1f90bbd6351f..4229d4d6e3ca1 100644
--- a/x-pack/plugins/security_solution/public/common/store/types.ts
+++ b/x-pack/plugins/security_solution/public/common/store/types.ts
@@ -5,7 +5,7 @@
* 2.0.
*/
-import type { Dispatch, Action, Middleware, CombinedState } from 'redux';
+import type { Store, Dispatch, Action, Middleware, CombinedState } from 'redux';
import type { CoreStart } from '@kbn/core/public';
import type { StartPlugins } from '../../types';
@@ -36,6 +36,11 @@ export type State = HostsPluginState &
globalUrlParam: GlobalUrlParam;
} & DataTableState;
+/**
+ * The Redux store type for the Security app.
+ */
+export type SecurityAppStore = Store;
+
/**
* like redux's `MiddlewareAPI` but `getState` returns an `Immutable` version of
* state and `dispatch` accepts `Immutable` versions of actions.
diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx
index 741e485d34dae..71f9ff1e5e9a4 100644
--- a/x-pack/plugins/security_solution/public/plugin.tsx
+++ b/x-pack/plugins/security_solution/public/plugin.tsx
@@ -6,7 +6,6 @@
*/
import { i18n } from '@kbn/i18n';
-import type { Action, Store } from 'redux';
import type { Subscription } from 'rxjs';
import { Subject } from 'rxjs';
import { combineLatestWith } from 'rxjs/operators';
@@ -58,11 +57,8 @@ import { getLazyEndpointGenericErrorsListExtension } from './management/pages/po
import type { ExperimentalFeatures } from '../common/experimental_features';
import { parseExperimentalConfigValue } from '../common/experimental_features';
import { LazyEndpointCustomAssetsExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_custom_assets_extension';
-import type { State } from './common/store/types';
-/**
- * The Redux store type for the Security app.
- */
-export type SecurityAppStore = Store;
+import type { SecurityAppStore } from './common/store/types';
+
export class Plugin implements IPlugin {
readonly kibanaVersion: string;
private config: SecuritySolutionUiConfigType;
@@ -89,6 +85,7 @@ export class Plugin implements IPlugin,
@@ -162,12 +159,15 @@ export class Plugin implements IPlugin {
return columnId && !FIELDS_WITHOUT_CELL_ACTIONS.includes(columnId);
};
+const StyledContent = styled.div<{ $isDetails: boolean }>`
+ padding: ${({ $isDetails }) => ($isDetails ? '0 8px' : undefined)};
+`;
+
export const DefaultCellRenderer: React.FC = ({
data,
ecsData,
@@ -33,10 +35,8 @@ export const DefaultCellRenderer: React.FC = ({
isTimeline,
linkValues,
rowRenderers,
- setCellProps,
scopeId,
truncate,
- closeCellPopover,
enableActions = true,
}) => {
const asPlainText = useMemo(() => {
@@ -74,7 +74,6 @@ export const DefaultCellRenderer: React.FC = ({
globalFilters={globalFilters}
scopeId={scopeId}
value={values}
- closeCellPopover={closeCellPopover}
/>
)}
>