diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index b26d368caa438..a16f1c2ad77ab 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -77,6 +77,8 @@ describe('PartitionVisComponent', function () { syncColors: false, fireEvent: jest.fn(), renderComplete: jest.fn(), + interactive: true, + columnCellValueActions: [], services: { data: dataPluginMock.createStartContract(), fieldFormats: fieldFormatsServiceMock.createStartContract(), @@ -172,6 +174,16 @@ describe('PartitionVisComponent', function () { }); }); + it('should render legend actions when it is interactive', async () => { + const component = shallow(); + expect(component.find(Settings).prop('legendAction')).toBeDefined(); + }); + + it('should not render legend actions when it is not interactive', async () => { + const component = shallow(); + expect(component.find(Settings).prop('legendAction')).toBeUndefined(); + }); + it('hides the legend if the legend toggle is clicked', async () => { const component = mountWithIntl(); await actWithTimeout(async () => { diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index 352f03f59e619..843d6075ac60d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -68,7 +68,7 @@ import { } from './partition_vis_component.styles'; import { ChartTypes } from '../../common/types'; import { filterOutConfig } from '../utils/filter_out_config'; -import { FilterEvent, StartDeps } from '../types'; +import { ColumnCellValueActions, FilterEvent, StartDeps } from '../types'; declare global { interface Window { @@ -85,19 +85,23 @@ export interface PartitionVisComponentProps { uiState: PersistedState; fireEvent: IInterpreterRenderHandlers['event']; renderComplete: IInterpreterRenderHandlers['done']; + interactive: boolean; chartsThemeService: ChartsPluginSetup['theme']; palettesRegistry: PaletteRegistry; services: Pick; syncColors: boolean; + columnCellValueActions: ColumnCellValueActions; } const PartitionVisComponent = (props: PartitionVisComponentProps) => { const { + columnCellValueActions, visData: originalVisData, visParams: preVisParams, visType, services, syncColors, + interactive, } = props; const visParams = useMemo(() => filterOutConfig(visType, preVisParams), [preVisParams, visType]); const chartTheme = props.chartsThemeService.useChartsTheme(); @@ -313,6 +317,32 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { ] ); + const legendActions = useMemo( + () => + interactive + ? getLegendActions( + canFilter, + getLegendActionEventData(visData), + handleLegendAction, + columnCellValueActions, + visParams, + visData, + services.data.actions, + services.fieldFormats + ) + : undefined, + [ + columnCellValueActions, + getLegendActionEventData, + handleLegendAction, + interactive, + services.data.actions, + services.fieldFormats, + visData, + visParams, + ] + ); + const rescaleFactor = useMemo(() => { const overallSum = visData.rows.reduce((sum, row) => sum + row[metricColumn.id], 0); const slices = visData.rows.map((row) => row[metricColumn.id] / overallSum); @@ -446,15 +476,7 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { splitChartFormatter ); }} - legendAction={getLegendActions( - canFilter, - getLegendActionEventData(visData), - handleLegendAction, - visParams, - visData, - services.data.actions, - services.fieldFormats - )} + legendAction={legendActions} theme={[ // Chart background should be transparent for the usage at Canvas. { background: { color: 'transparent' } }, diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.test.tsx new file mode 100644 index 0000000000000..d007d36177857 --- /dev/null +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { createMockPieParams, createMockVisData } from '../mocks'; +import { CellValueAction } from '../types'; +import { getColumnCellValueActions } from './partition_vis_renderer'; + +const visParams = createMockPieParams(); +const visData = createMockVisData(); + +const cellValueAction: CellValueAction = { + displayName: 'Test', + id: 'test', + iconType: 'test-icon', + execute: () => {}, +}; + +describe('getColumnCellValueActions', () => { + it('should get column cellValue actions for each params bucket', async () => { + const result = await getColumnCellValueActions(visParams, visData, async () => [ + cellValueAction, + ]); + expect(result).toHaveLength(visParams.dimensions.buckets?.length ?? 0); + }); + + it('should contain the cellValue actions', async () => { + const result = await getColumnCellValueActions(visParams, visData, async () => [ + cellValueAction, + cellValueAction, + ]); + expect(result[0]).toEqual([cellValueAction, cellValueAction]); + }); + + it('should return empty array if no buckets', async () => { + const result = await getColumnCellValueActions( + { ...visParams, dimensions: { ...visParams.dimensions, buckets: undefined } }, + visData, + async () => [cellValueAction] + ); + expect(result).toEqual([]); + }); + + it('should return empty array if getCompatibleCellValueActions not passed', async () => { + const result = await getColumnCellValueActions(visParams, visData, undefined); + expect(result).toEqual([]); + }); +}); diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx index 4b6fe45e4df92..50198f556f919 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/expression_renderers/partition_vis_renderer.tsx @@ -11,14 +11,20 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { I18nProvider } from '@kbn/i18n-react'; import { css } from '@emotion/react'; import { i18n } from '@kbn/i18n'; -import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/public'; +import type { + Datatable, + ExpressionRenderDefinition, + IInterpreterRenderHandlers, +} from '@kbn/expressions-plugin/public'; import type { PersistedState } from '@kbn/visualizations-plugin/public'; import { withSuspense } from '@kbn/presentation-util-plugin/public'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { METRIC_TYPE } from '@kbn/analytics'; +import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import { VisTypePieDependencies } from '../plugin'; import { PARTITION_VIS_RENDERER_NAME } from '../../common/constants'; -import { ChartTypes, RenderValue } from '../../common/types'; +import { CellValueAction, GetCompatibleCellValueActions } from '../types'; +import { ChartTypes, PartitionVisParams, RenderValue } from '../../common/types'; // eslint-disable-next-line @kbn/imports/no_boundary_crossing import { extractContainerType, extractVisualizationType } from '../../../common'; @@ -42,6 +48,30 @@ const partitionVisRenderer = css({ height: '100%', }); +/** + * Retrieves the compatible CELL_VALUE_TRIGGER actions indexed by column + **/ +export const getColumnCellValueActions = async ( + visConfig: PartitionVisParams, + visData: Datatable, + getCompatibleCellValueActions?: IInterpreterRenderHandlers['getCompatibleCellValueActions'] +) => { + if (!Array.isArray(visConfig.dimensions.buckets) || !getCompatibleCellValueActions) { + return []; + } + return Promise.all( + visConfig.dimensions.buckets.reduce>>((acc, accessor) => { + const columnMeta = getColumnByAccessor(accessor, visData.columns)?.meta; + if (columnMeta) { + acc.push( + (getCompatibleCellValueActions as GetCompatibleCellValueActions)([{ columnMeta }]) + ); + } + return acc; + }, []) + ); +}; + export const getPartitionVisRenderer: ( deps: VisTypePieDependencies ) => ExpressionRenderDefinition = ({ getStartDeps }) => ({ @@ -76,7 +106,10 @@ export const getPartitionVisRenderer: ( handlers.done(); }; - const palettesRegistry = await plugins.charts.palettes.getPalettes(); + const [columnCellValueActions, palettesRegistry] = await Promise.all([ + getColumnCellValueActions(visConfig, visData, handlers.getCompatibleCellValueActions), + plugins.charts.palettes.getPalettes(), + ]); render( @@ -90,9 +123,11 @@ export const getPartitionVisRenderer: ( visType={visConfig.isDonut ? ChartTypes.DONUT : visType} renderComplete={renderComplete} fireEvent={handlers.event} + interactive={handlers.isInteractive()} uiState={handlers.uiState as PersistedState} services={{ data: plugins.data, fieldFormats: plugins.fieldFormats }} syncColors={syncColors} + columnCellValueActions={columnCellValueActions} /> diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/types.ts b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts index 093a65c8cbf5d..77636d3a41832 100755 --- a/src/plugins/chart_expressions/expression_partition_vis/public/types.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/types.ts @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import type { ValueClickContext } from '@kbn/embeddable-plugin/public'; +import type { CellValueContext, ValueClickContext } from '@kbn/embeddable-plugin/public'; import { ChartsPluginSetup, ChartsPluginStart } from '@kbn/charts-plugin/public'; import { Plugin as ExpressionsPublicPlugin, @@ -35,3 +35,16 @@ export interface FilterEvent { name: 'filter'; data: ValueClickContext['data']; } + +export interface CellValueAction { + id: string; + iconType: string; + displayName: string; + execute: (data: CellValueContext['data']) => void; +} + +export type ColumnCellValueActions = CellValueAction[][]; + +export type GetCompatibleCellValueActions = ( + data: CellValueContext['data'] +) => Promise; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts index 6a42bc6f7b601..4be6f2564e00d 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/filter_helpers.ts @@ -125,3 +125,7 @@ export const getFilterEventData = ( return acc; }, []); }; + +export const getSeriesValueColumnIndex = (value: string, visData: Datatable): number => { + return visData.columns.findIndex(({ id }) => !!visData.rows.find((r) => r[id] === value)); +}; diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx index fcaa5fb98f93f..9762b446bd7af 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/utils/get_legend_actions.tsx @@ -16,7 +16,8 @@ import { Datatable } from '@kbn/expressions-plugin/public'; import { getFormatByAccessor, getAccessor } from '@kbn/visualizations-plugin/common/utils'; import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import { PartitionVisParams } from '../../common/types'; -import { FilterEvent } from '../types'; +import { ColumnCellValueActions, FilterEvent } from '../types'; +import { getSeriesValueColumnIndex } from './filter_helpers'; export const getLegendActions = ( canFilter: ( @@ -25,6 +26,7 @@ export const getLegendActions = ( ) => Promise, getFilterEventData: (series: SeriesIdentifier) => FilterEvent | null, onFilter: (data: FilterEvent, negate?: any) => void, + columnCellValueActions: ColumnCellValueActions, visParams: PartitionVisParams, visData: Datatable, actions: DataPublicPluginStart['actions'], @@ -32,58 +34,87 @@ export const getLegendActions = ( ): LegendAction => { return ({ series: [pieSeries] }) => { const [popoverOpen, setPopoverOpen] = useState(false); - const [isfilterable, setIsfilterable] = useState(true); + const [isFilterable, setIsFilterable] = useState(true); const filterData = useMemo(() => getFilterEventData(pieSeries), [pieSeries]); + const columnIndex = useMemo( + () => getSeriesValueColumnIndex(pieSeries.key, visData), + [pieSeries] + ); const [ref, onClose] = useLegendAction(); useEffect(() => { - (async () => setIsfilterable(await canFilter(filterData, actions)))(); + (async () => setIsFilterable(await canFilter(filterData, actions)))(); }, [filterData]); - if (!isfilterable || !filterData) { + if (columnIndex === -1) { return null; } let formattedTitle = ''; if (visParams.dimensions.buckets) { const accessor = visParams.dimensions.buckets.find( - (bucket) => getAccessor(bucket) === filterData.data.data[0].column + (bucket) => getAccessor(bucket) === columnIndex ); formattedTitle = formatter .deserialize(accessor ? getFormatByAccessor(accessor, visData.columns) : undefined) .convert(pieSeries.key) ?? ''; } - const title = formattedTitle || pieSeries.key; + + const panelItems: EuiContextMenuPanelDescriptor['items'] = []; + + if (isFilterable && filterData) { + panelItems.push( + { + name: i18n.translate('expressionPartitionVis.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for value', + }), + 'data-test-subj': `legend-${title}-filterIn`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData); + }, + }, + { + name: i18n.translate('expressionPartitionVis.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out value', + }), + 'data-test-subj': `legend-${title}-filterOut`, + icon: , + onClick: () => { + setPopoverOpen(false); + onFilter(filterData, true); + }, + } + ); + } + + if (columnCellValueActions[columnIndex]) { + const columnMeta = visData.columns[columnIndex].meta; + columnCellValueActions[columnIndex].forEach((action) => { + panelItems.push({ + name: action.displayName, + 'data-test-subj': `legend-${title}-${action.id}`, + icon: , + onClick: () => { + action.execute([{ columnMeta, value: pieSeries.key }]); + setPopoverOpen(false); + }, + }); + }); + } + + if (panelItems.length === 0) { + return null; + } + const panels: EuiContextMenuPanelDescriptor[] = [ { id: 'main', - title: `${title}`, - items: [ - { - name: i18n.translate('expressionPartitionVis.legend.filterForValueButtonAriaLabel', { - defaultMessage: 'Filter for value', - }), - 'data-test-subj': `legend-${title}-filterIn`, - icon: , - onClick: () => { - setPopoverOpen(false); - onFilter(filterData); - }, - }, - { - name: i18n.translate('expressionPartitionVis.legend.filterOutValueButtonAriaLabel', { - defaultMessage: 'Filter out value', - }), - 'data-test-subj': `legend-${title}-filterOut`, - icon: , - onClick: () => { - setPopoverOpen(false); - onFilter(filterData, true); - }, - }, - ], + title, + items: panelItems, }, ]; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx index f82428993ce46..1398fc64357cb 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.test.tsx @@ -15,11 +15,15 @@ import { ComponentType, ReactWrapper } from 'enzyme'; import type { DataLayerConfig } from '../../common'; import { LayerTypes } from '../../common/constants'; import { getLegendAction } from './legend_action'; -import { LegendActionPopover } from './legend_action_popover'; +import { LegendActionPopover, LegendCellValueActions } from './legend_action_popover'; import { mockPaletteOutput } from '../../common/__mocks__'; import { FieldFormat } from '@kbn/field-formats-plugin/common'; import { LayerFieldFormats } from '../helpers'; +const legendCellValueActions: LegendCellValueActions = [ + { id: 'action_1', displayName: 'Action 1', iconType: 'testIcon1', execute: () => {} }, + { id: 'action_2', displayName: 'Action 2', iconType: 'testIcon2', execute: () => {} }, +]; const table: Datatable = { type: 'datatable', rows: [ @@ -178,6 +182,7 @@ describe('getLegendAction', function () { const Component: ComponentType = getLegendAction( [sampleLayer], jest.fn(), + [legendCellValueActions], { first: { splitSeriesAccessors: { @@ -251,15 +256,8 @@ describe('getLegendAction', function () { expect(wrapper.find(EuiPopover).prop('title')).toEqual( "Women's Accessories - Label B, filter options" ); - expect(wrapper.find(LegendActionPopover).prop('context')).toEqual({ - data: [ - { - column: 1, - row: 1, - table, - value: "Women's Accessories", - }, - ], - }); + expect(wrapper.find(LegendActionPopover).prop('legendCellValueActions')).toEqual( + legendCellValueActions.map((action) => ({ ...action, execute: expect.any(Function) })) + ); }); }); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx index 37789a6ffa15e..f5b00f696d04f 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action.tsx @@ -9,9 +9,10 @@ import React from 'react'; import type { LegendAction, XYChartSeriesIdentifier } from '@elastic/charts'; import { getAccessorByDimension } from '@kbn/visualizations-plugin/common/utils'; -import type { FilterEvent } from '../types'; +import { CellValueContext } from '@kbn/embeddable-plugin/public'; +import type { LayerCellValueActions, FilterEvent } from '../types'; import type { CommonXYDataLayerConfig } from '../../common'; -import { LegendActionPopover } from './legend_action_popover'; +import { LegendActionPopover, LegendCellValueActions } from './legend_action_popover'; import { DatatablesWithFormatInfo, getSeriesName, @@ -23,6 +24,7 @@ import { export const getLegendAction = ( dataLayers: CommonXYDataLayerConfig[], onFilter: (data: FilterEvent['data']) => void, + layerCellValueActions: LayerCellValueActions, fieldFormats: LayersFieldFormats, formattedDatatables: DatatablesWithFormatInfo, titles: LayersAccessorsTitles, @@ -50,30 +52,44 @@ export const getLegendAction = ( const { table } = layer; - const data: FilterEvent['data']['data'] = []; + const filterActionData: FilterEvent['data']['data'] = []; + const cellValueActionData: CellValueContext['data'] = []; series.splitAccessors.forEach((value, accessor) => { const rowIndex = formattedDatatables[layer.layerId].table.rows.findIndex((row) => { return row[accessor] === value; }); - if (rowIndex !== -1) { - data.push({ + const columnIndex = table.columns.findIndex((column) => column.id === accessor); + + if (rowIndex >= 0 && columnIndex >= 0) { + filterActionData.push({ row: rowIndex, - column: table.columns.findIndex((column) => column.id === accessor), + column: columnIndex, value: table.rows[rowIndex][accessor], table, }); + + cellValueActionData.push({ + value: table.rows[rowIndex][accessor], + columnMeta: table.columns[columnIndex].meta, + }); } }); - if (data.length === 0) { + if (filterActionData.length === 0) { return null; } - const context: FilterEvent['data'] = { - data, + const filterHandler = ({ negate }: { negate?: boolean } = {}) => { + onFilter({ data: filterActionData, negate }); }; + const legendCellValueActions: LegendCellValueActions = + layerCellValueActions[layerIndex]?.map((action) => ({ + ...action, + execute: () => action.execute(cellValueActionData), + })) ?? []; + return ( ); }); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/legend_action_popover.tsx b/src/plugins/chart_expressions/expression_xy/public/components/legend_action_popover.tsx index be93140e734dc..e01ed18b106ce 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/legend_action_popover.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/legend_action_popover.tsx @@ -10,7 +10,11 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; import { useLegendAction } from '@elastic/charts'; -import type { FilterEvent } from '../types'; +import type { CellValueAction } from '../types'; + +export type LegendCellValueActions = Array< + Omit & { execute: () => void } +>; export interface LegendActionPopoverProps { /** @@ -20,20 +24,31 @@ export interface LegendActionPopoverProps { /** * Callback on filter value */ - onFilter: (data: FilterEvent['data']) => void; + onFilter: (param?: { negate?: boolean }) => void; /** - * Determines the filter event data + * Compatible actions to be added to the popover actions */ - context: FilterEvent['data']; + legendCellValueActions?: LegendCellValueActions; } export const LegendActionPopover: React.FunctionComponent = ({ label, onFilter, - context, + legendCellValueActions = [], }) => { const [popoverOpen, setPopoverOpen] = useState(false); const [ref, onClose] = useLegendAction(); + + const legendCellValueActionPanelItems = legendCellValueActions.map((action) => ({ + name: action.displayName, + 'data-test-subj': `legend-${label}-${action.id}`, + icon: , + onClick: () => { + action.execute(); + setPopoverOpen(false); + }, + })); + const panels: EuiContextMenuPanelDescriptor[] = [ { id: 'main', @@ -47,7 +62,7 @@ export const LegendActionPopover: React.FunctionComponent, onClick: () => { setPopoverOpen(false); - onFilter(context); + onFilter(); }, }, { @@ -58,9 +73,10 @@ export const LegendActionPopover: React.FunctionComponent, onClick: () => { setPopoverOpen(false); - onFilter({ ...context, negate: true }); + onFilter({ negate: true }); }, }, + ...legendCellValueActionPanelItems, ], }, ]; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index b6777d36c1d9e..f8e41ca535828 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -58,8 +58,10 @@ import { ExtendedDataLayerConfig, XYProps, AnnotationLayerConfigResult } from '. import { DataLayers } from './data_layers'; import { SplitChart } from './split_chart'; import { LegendSize } from '@kbn/visualizations-plugin/common'; +import type { LayerCellValueActions } from '../types'; const onClickValue = jest.fn(); +const layerCellValueActions: LayerCellValueActions = []; const onSelectRange = jest.fn(); describe('XYChart component', () => { @@ -114,6 +116,7 @@ describe('XYChart component', () => { paletteService, minInterval: 50, onClickValue, + layerCellValueActions, onSelectRange, syncColors: false, syncTooltips: false, diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index d57dc86b36ba1..b9f8479c130f6 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -47,7 +47,7 @@ import { LegendSizeToPixels, } from '@kbn/visualizations-plugin/common/constants'; import { PersistedState } from '@kbn/visualizations-plugin/public'; -import type { FilterEvent, BrushEvent, FormatFactory } from '../types'; +import type { FilterEvent, BrushEvent, FormatFactory, LayerCellValueActions } from '../types'; import { isTimeChart } from '../../common/helpers'; import type { CommonXYDataLayerConfig, @@ -121,6 +121,7 @@ export type XYChartRenderProps = Omit & { minInterval: number | undefined; interactive?: boolean; onClickValue: (data: FilterEvent['data']) => void; + layerCellValueActions: LayerCellValueActions; onSelectRange: (data: BrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; @@ -196,6 +197,7 @@ export function XYChart({ paletteService, minInterval, onClickValue, + layerCellValueActions, onSelectRange, interactive = true, syncColors, @@ -828,6 +830,7 @@ export function XYChart({ ? getLegendAction( dataLayers, onClickValue, + layerCellValueActions, fieldFormats, formattedDatatables, titles, diff --git a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx index 085cf9b8c7bb8..fef4b57671b65 100644 --- a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx @@ -18,7 +18,10 @@ import { PersistedState } from '@kbn/visualizations-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { EventAnnotationServiceType } from '@kbn/event-annotation-plugin/public'; -import { ExpressionRenderDefinition } from '@kbn/expressions-plugin/common'; +import type { + ExpressionRenderDefinition, + IInterpreterRenderHandlers, +} from '@kbn/expressions-plugin/common'; import { FormatFactory } from '@kbn/field-formats-plugin/common'; import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public'; @@ -26,8 +29,8 @@ import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; import type { getDataLayers } from '../helpers'; import { LayerTypes, SeriesTypes } from '../../common/constants'; -import type { XYChartProps } from '../../common'; -import type { BrushEvent, FilterEvent } from '../types'; +import type { CommonXYDataLayerConfig, XYChartProps } from '../../common'; +import type { BrushEvent, FilterEvent, GetCompatibleCellValueActions } from '../types'; // eslint-disable-next-line @kbn/imports/no_boundary_crossing import { extractContainerType, extractVisualizationType } from '../../../common'; @@ -157,6 +160,28 @@ const extractCounterEvents = ( } }; +/** + * Retrieves the compatible CELL_VALUE_TRIGGER actions indexed by layer + **/ +const getLayerCellValueActions = async ( + layers: CommonXYDataLayerConfig[], + getCompatibleCellValueActions?: IInterpreterRenderHandlers['getCompatibleCellValueActions'] +) => { + if (!layers || !getCompatibleCellValueActions) { + return []; + } + return await Promise.all( + layers.map((layer) => { + const data = + layer.splitAccessors?.map((accessor) => { + const column = layer.table.columns.find(({ id }) => id === accessor); + return { columnMeta: column?.meta }; + }) ?? []; + return (getCompatibleCellValueActions as GetCompatibleCellValueActions)(data); + }) + ); +}; + export const getXyChartRenderer = ({ getStartDeps, }: XyChartRendererDeps): ExpressionRenderDefinition => ({ @@ -184,6 +209,11 @@ export const getXyChartRenderer = ({ handlers.event({ name: 'brush', data }); }; + const layerCellValueActions = await getLayerCellValueActions( + getDataLayers(config.args.layers), + handlers.getCompatibleCellValueActions as GetCompatibleCellValueActions | undefined + ); + const renderComplete = () => { const executionContext = handlers.getExecutionContext(); const containerType = extractContainerType(executionContext); @@ -231,6 +261,7 @@ export const getXyChartRenderer = ({ minInterval={calculateMinInterval(deps.data.datatableUtilities, config)} interactive={handlers.isInteractive()} onClickValue={onClickValue} + layerCellValueActions={layerCellValueActions} onSelectRange={onSelectRange} renderMode={handlers.getRenderMode()} syncColors={config.syncColors} diff --git a/src/plugins/chart_expressions/expression_xy/public/types.ts b/src/plugins/chart_expressions/expression_xy/public/types.ts index b58641f2c58fa..f9b4d1b0131f8 100755 --- a/src/plugins/chart_expressions/expression_xy/public/types.ts +++ b/src/plugins/chart_expressions/expression_xy/public/types.ts @@ -11,7 +11,11 @@ import { DataPublicPluginSetup } from '@kbn/data-plugin/public'; import { FieldFormatsSetup } from '@kbn/field-formats-plugin/public'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import { IFieldFormat, SerializedFieldFormat } from '@kbn/field-formats-plugin/common'; -import type { RangeSelectContext, ValueClickContext } from '@kbn/embeddable-plugin/public'; +import type { + CellValueContext, + RangeSelectContext, + ValueClickContext, +} from '@kbn/embeddable-plugin/public'; import { ExpressionsServiceStart, ExpressionsSetup } from '@kbn/expressions-plugin/public'; export interface SetupDeps { @@ -114,3 +118,16 @@ export interface AccessorConfig { color?: string; palette?: string[] | Array<{ color: string; stop: number }>; } + +export interface CellValueAction { + id: string; + iconType: string; + displayName: string; + execute: (data: CellValueContext['data']) => void; +} + +export type LayerCellValueActions = CellValueAction[][]; + +export type GetCompatibleCellValueActions = ( + data: CellValueContext['data'] +) => Promise; diff --git a/src/plugins/embeddable/public/bootstrap.ts b/src/plugins/embeddable/public/bootstrap.ts index 4e21457b6e758..5d28e392ddf6d 100644 --- a/src/plugins/embeddable/public/bootstrap.ts +++ b/src/plugins/embeddable/public/bootstrap.ts @@ -13,6 +13,7 @@ import { panelNotificationTrigger, selectRangeTrigger, valueClickTrigger, + cellValueTrigger, } from './lib'; /** @@ -25,4 +26,5 @@ export const bootstrap = (uiActions: UiActionsSetup) => { uiActions.registerTrigger(panelNotificationTrigger); uiActions.registerTrigger(selectRangeTrigger); uiActions.registerTrigger(valueClickTrigger); + uiActions.registerTrigger(cellValueTrigger); }; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 4b4d63f4596a4..627a8cd50fac1 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -29,6 +29,7 @@ export type { EmbeddableInstanceConfiguration, EmbeddableOutput, ValueClickContext, + CellValueContext, RangeSelectContext, IContainer, IEmbeddable, @@ -68,6 +69,8 @@ export { PanelNotFoundError, SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, + CELL_VALUE_TRIGGER, + cellValueTrigger, ViewMode, withEmbeddableSubscription, genericEmbeddableInputIsEqual, diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 7db9c06687947..ee786d930e0cf 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -7,7 +7,7 @@ */ import { i18n } from '@kbn/i18n'; -import { Datatable } from '@kbn/expressions-plugin/common'; +import { Datatable, DatatableColumnMeta } from '@kbn/expressions-plugin/common'; import { Trigger, RowClickContext } from '@kbn/ui-actions-plugin/public'; import { IEmbeddable } from '..'; @@ -29,6 +29,15 @@ export interface ValueClickContext { }; } +export interface CellValueContext { + embeddable: T; + data: Array<{ + value?: any; + eventId?: string; + columnMeta?: DatatableColumnMeta; + }>; +} + export interface RangeSelectContext { embeddable?: T; data: { @@ -99,6 +108,17 @@ export const valueClickTrigger: Trigger = { }), }; +export const CELL_VALUE_TRIGGER = 'CELL_VALUE_TRIGGER'; +export const cellValueTrigger: Trigger = { + id: CELL_VALUE_TRIGGER, + title: i18n.translate('embeddableApi.cellValueTrigger.title', { + defaultMessage: 'Cell value', + }), + description: i18n.translate('embeddableApi.cellValueTrigger.description', { + defaultMessage: 'Actions appear in the cell value options on the visualization', + }), +}; + export const isValueClickTriggerContext = ( context: ChartActionContext ): context is ValueClickContext => context.data && 'data' in context.data; diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 8f5fc21e205c7..7dae307aa6c01 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -84,6 +84,7 @@ export interface IInterpreterRenderHandlers { update(params: IInterpreterRenderUpdateParams): void; event(event: IInterpreterRenderEvent): void; hasCompatibleActions?(event: IInterpreterRenderEvent): Promise; + getCompatibleCellValueActions?(data: object[]): Promise; getRenderMode(): RenderMode; /** diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index e2b9d8ab22fb7..f10b8db1f1287 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -61,6 +61,7 @@ export class ExpressionLoader { syncTooltips: params?.syncTooltips, syncCursor: params?.syncCursor, hasCompatibleActions: params?.hasCompatibleActions, + getCompatibleCellValueActions: params?.getCompatibleCellValueActions, executionContext: params?.executionContext, }); this.render$ = this.renderHandler.render$; diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index f522ea5fe5971..a5b79f20b6fc9 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -143,6 +143,28 @@ describe('ExpressionRenderHandler', () => { }); }); + it('should pass through provided "getCompatibleCellValueActions" to the expression renderer', async () => { + const getCompatibleCellValueActions = jest.fn(); + const cellValueActionsParameter = [{ value: 'testValue' }]; + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ get: () => true }); + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ + get: () => ({ + render: (domNode: HTMLElement, config: unknown, handlers: IInterpreterRenderHandlers) => { + handlers.getCompatibleCellValueActions!(cellValueActionsParameter); + }, + }), + }); + + const expressionRenderHandler = new ExpressionRenderHandler(element, { + onRenderError: mockMockErrorRenderFunction, + getCompatibleCellValueActions, + }); + expect(getCompatibleCellValueActions).toHaveBeenCalledTimes(0); + await expressionRenderHandler.render({ type: 'render', as: 'something' }); + expect(getCompatibleCellValueActions).toHaveBeenCalledTimes(1); + expect(getCompatibleCellValueActions).toHaveBeenCalledWith(cellValueActionsParameter); + }); + it('sends a next observable once rendering is complete', () => { const expressionRenderHandler = new ExpressionRenderHandler(element); expect.assertions(1); diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index fd550b2888316..a7b919625b8d6 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -34,6 +34,7 @@ export interface ExpressionRenderHandlerParams { syncTooltips?: boolean; interactive?: boolean; hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; + getCompatibleCellValueActions?: (data: object[]) => Promise; executionContext?: KibanaExecutionContext; } @@ -63,6 +64,7 @@ export class ExpressionRenderHandler { syncCursor, interactive, hasCompatibleActions = async () => false, + getCompatibleCellValueActions = async () => [], executionContext, }: ExpressionRenderHandlerParams = {} ) { @@ -115,6 +117,7 @@ export class ExpressionRenderHandler { return interactive ?? true; }, hasCompatibleActions, + getCompatibleCellValueActions, }; } diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index c47eb4592fa4f..870b44e9bc02c 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -53,6 +53,7 @@ export interface IExpressionLoaderParams { syncCursor?: boolean; syncTooltips?: boolean; hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; + getCompatibleCellValueActions?: ExpressionRenderHandlerParams['getCompatibleCellValueActions']; executionContext?: KibanaExecutionContext; /** diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 5af66f23d1eb7..637ce344bcaaf 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -52,8 +52,11 @@ import { ReferenceOrValueEmbeddable, SelfStyledEmbeddable, FilterableEmbeddable, + cellValueTrigger, + CELL_VALUE_TRIGGER, + type CellValueContext, } from '@kbn/embeddable-plugin/public'; -import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; +import type { Action, UiActionsStart } from '@kbn/ui-actions-plugin/public'; import type { DataViewsContract, DataView } from '@kbn/data-views-plugin/public'; import type { Capabilities, @@ -79,6 +82,7 @@ import { DatasourceMap, Datasource, IndexPatternMap, + GetCompatibleCellValueActions, } from '../types'; import { getEditPath, DOC_TYPE } from '../../common'; @@ -732,6 +736,7 @@ export class Embeddable syncTooltips={input.syncTooltips} syncCursor={input.syncCursor} hasCompatibleActions={this.hasCompatibleActions} + getCompatibleCellValueActions={this.getCompatibleCellValueActions} className={input.className} style={input.style} executionContext={this.getExecutionContext()} @@ -779,6 +784,27 @@ export class Embeddable return false; }; + private readonly getCompatibleCellValueActions: GetCompatibleCellValueActions = async (data) => { + const { getTriggerCompatibleActions } = this.deps; + if (getTriggerCompatibleActions) { + const embeddable = this; + const actions: Array> = await getTriggerCompatibleActions( + CELL_VALUE_TRIGGER, + { data, embeddable } + ); + return actions + .sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity)) + .map((action) => ({ + id: action.id, + iconType: action.getIconType({ embeddable, data, trigger: cellValueTrigger })!, + displayName: action.getDisplayName({ embeddable, data, trigger: cellValueTrigger }), + execute: (cellData) => + action.execute({ embeddable, data: cellData, trigger: cellValueTrigger }), + })); + } + return []; + }; + /** * Combines the embeddable context with the saved object context, and replaces * any references to index patterns diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index cbb1fedf75497..d5aaf99788ae7 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -11,8 +11,8 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon, EuiEmptyPrompt } from '@elastic/eui'; import { ExpressionRendererEvent, - ReactExpressionRendererType, ReactExpressionRendererProps, + ReactExpressionRendererType, } from '@kbn/expressions-plugin/public'; import type { KibanaExecutionContext } from '@kbn/core/public'; import { ExecutionContextSearch } from '@kbn/data-plugin/public'; @@ -41,6 +41,7 @@ export interface ExpressionWrapperProps { syncTooltips?: boolean; syncCursor?: boolean; hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions']; + getCompatibleCellValueActions?: ReactExpressionRendererProps['getCompatibleCellValueActions']; style?: React.CSSProperties; className?: string; canEdit: boolean; @@ -116,6 +117,7 @@ export function ExpressionWrapper({ syncTooltips, syncCursor, hasCompatibleActions, + getCompatibleCellValueActions, style, className, errors, @@ -168,6 +170,7 @@ export function ExpressionWrapper({ }} onEvent={handleEvent} hasCompatibleActions={hasCompatibleActions} + getCompatibleCellValueActions={getCompatibleCellValueActions} /> )} diff --git a/x-pack/plugins/lens/public/index.ts b/x-pack/plugins/lens/public/index.ts index 7a908fb0db4eb..9bda68f5193ab 100644 --- a/x-pack/plugins/lens/public/index.ts +++ b/x-pack/plugins/lens/public/index.ts @@ -107,6 +107,7 @@ export type { LensEmbeddableInput, LensSavedObjectAttributes, Embeddable } from export type { ChartInfo } from './chart_info_api'; export { layerTypes } from '../common/layer_types'; +export { LENS_EMBEDDABLE_TYPE } from '../common/constants'; export type { LensPublicStart, LensPublicSetup } from './plugin'; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 4cc0b4c4948fa..f37f0b8369cd8 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -13,9 +13,9 @@ import type { MutableRefObject } from 'react'; import type { Filter, TimeRange } from '@kbn/es-query'; import type { ExpressionAstExpression, - ExpressionRendererEvent, IInterpreterRenderHandlers, Datatable, + ExpressionRendererEvent, } from '@kbn/expressions-plugin/public'; import type { Configuration, NavigateToLensContext } from '@kbn/visualizations-plugin/common'; import { Adapters } from '@kbn/inspector-plugin/public'; @@ -35,6 +35,7 @@ import type { EuiButtonIconProps } from '@elastic/eui'; import { SearchRequest } from '@kbn/data-plugin/public'; import { estypes } from '@elastic/elasticsearch'; import React from 'react'; +import { CellValueContext } from '@kbn/embeddable-plugin/public'; import type { DraggingIdentifier, DragDropIdentifier, DragContextState } from './drag_drop'; import type { DateRange, LayerType, SortingHint } from '../common'; import type { @@ -1335,7 +1336,7 @@ export function isLensEditEvent( export function isLensTableRowContextMenuClickEvent( event: ExpressionRendererEvent -): event is BrushTriggerEvent { +): event is LensTableRowContextMenuEvent { return event.name === 'tableRowContextMenuClick'; } @@ -1376,3 +1377,14 @@ export type LensTopNavMenuEntryGenerator = (props: { initialContext?: VisualizeFieldContext | VisualizeEditorContext; currentDoc: Document | undefined; }) => undefined | TopNavMenuData; + +export interface LensCellValueAction { + id: string; + iconType: string; + displayName: string; + execute: (data: CellValueContext['data']) => void; +} + +export type GetCompatibleCellValueActions = ( + data: CellValueContext['data'] +) => Promise; diff --git a/x-pack/plugins/lens/public/visualizations/datatable/components/__snapshots__/table_basic.test.tsx.snap b/x-pack/plugins/lens/public/visualizations/datatable/components/__snapshots__/table_basic.test.tsx.snap index ddce37121dfa4..8c3b9dbf4cf94 100644 --- a/x-pack/plugins/lens/public/visualizations/datatable/components/__snapshots__/table_basic.test.tsx.snap +++ b/x-pack/plugins/lens/public/visualizations/datatable/components/__snapshots__/table_basic.test.tsx.snap @@ -125,7 +125,7 @@ exports[`DatatableComponent it renders actions column when there are row actions "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "a", "id": "a", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -174,7 +175,7 @@ exports[`DatatableComponent it renders actions column when there are row actions "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "b", "id": "b", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -223,7 +225,7 @@ exports[`DatatableComponent it renders actions column when there are row actions "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "c", "id": "c", + "visibleCellActions": 5, }, ] } @@ -400,7 +403,7 @@ exports[`DatatableComponent it renders custom row height if set to another value "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "a", "id": "a", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -449,7 +453,7 @@ exports[`DatatableComponent it renders custom row height if set to another value "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "b", "id": "b", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -498,7 +503,7 @@ exports[`DatatableComponent it renders custom row height if set to another value "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "c", "id": "c", + "visibleCellActions": 5, }, ] } @@ -668,7 +674,7 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "a", "id": "a", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -717,7 +724,7 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "b", "id": "b", + "visibleCellActions": 5, }, Object { "actions": Object { @@ -766,7 +774,7 @@ exports[`DatatableComponent it renders the title and value 1`] = ` "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "displayAsText": "c", "id": "c", + "visibleCellActions": 5, }, ] } @@ -938,7 +947,7 @@ exports[`DatatableComponent it should render hide, reset, and sort actions on he "label": "Sort descending", }, }, - "cellActions": undefined, + "cellActions": Array [], "display":
, "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} /> )}