From 8103a445855f72dd1755a4fd4364e4573ec6a194 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Tue, 31 Oct 2023 17:30:08 +0000 Subject: [PATCH] [SecuritySolution] Fix topN legend actions - filter in / out in timeline (#170127) ## Summary https://github.com/elastic/kibana/issues/168199 https://github.com/elastic/kibana/issues/169656 https://github.com/elastic/kibana/assets/6295984/ff5cee55-6da5-4636-85f5-a697a302f8b5 --------- Co-authored-by: Michael Olorunnisola Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../layered_xy_vis.test.ts | 2 + .../expression_functions/layered_xy_vis_fn.ts | 2 + .../expression_functions/xy_vis.test.ts | 4 + .../common/expression_functions/xy_vis_fn.ts | 2 + .../common/helpers/visualization.ts | 2 + .../public/components/legend_action.test.tsx | 3 +- .../public/components/legend_action.tsx | 2 + .../components/legend_action_popover.tsx | 98 ++++++++++------ .../public/components/xy_chart.tsx | 3 + .../xy_chart_renderer.tsx | 1 + .../expressions/common/execution/types.ts | 2 + .../common/expression_renderers/types.ts | 1 + src/plugins/expressions/public/loader.ts | 1 + .../react_expression_renderer.tsx | 1 + src/plugins/expressions/public/render.ts | 5 + src/plugins/expressions/public/types/index.ts | 1 + .../lens/public/embeddable/embeddable.tsx | 2 + .../public/embeddable/expression_wrapper.tsx | 3 + .../public/actions/filter/index.ts | 4 + .../public/actions/filter/lens/filter_in.ts | 29 +++++ .../actions/filter/lens/filter_in_timeline.ts | 29 +++++ .../public/actions/filter/lens/filter_out.ts | 30 +++++ .../filter/lens/filter_out_timeline.ts | 30 +++++ .../public/actions/filter/lens/helpers.ts | 111 ++++++++++++++++++ .../public/actions/register.ts | 36 +++++- .../visualization_actions/lens_embeddable.tsx | 8 +- .../components/visualization_actions/utils.ts | 32 +++++ .../cypress/screens/alerts.ts | 6 +- 28 files changed, 405 insertions(+), 45 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/actions/filter/lens/filter_in.ts create mode 100644 x-pack/plugins/security_solution/public/actions/filter/lens/filter_in_timeline.ts create mode 100644 x-pack/plugins/security_solution/public/actions/filter/lens/filter_out.ts create mode 100644 x-pack/plugins/security_solution/public/actions/filter/lens/filter_out_timeline.ts create mode 100644 x-pack/plugins/security_solution/public/actions/filter/lens/helpers.ts diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts index eae70ea54f0c90..78eb7c54ebf8a9 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis.test.ts @@ -10,6 +10,7 @@ import { layeredXyVisFunction } from '.'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import { sampleArgs, sampleExtendedLayer } from '../__mocks__'; import { XY_VIS } from '../constants'; +import { shouldShowLegendActionDefault } from '../helpers/visualization'; describe('layeredXyVis', () => { test('it renders with the specified data and args', async () => { @@ -30,6 +31,7 @@ describe('layeredXyVis', () => { syncTooltips: false, syncCursor: true, canNavigateToLens: false, + shouldShowLegendAction: shouldShowLegendActionDefault, }, }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts index cf1325f09bf22a..5a1a79ef984fcd 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/layered_xy_vis_fn.ts @@ -18,6 +18,7 @@ import { validateAxes, } from './validate'; import { appendLayerIds, getDataLayers } from '../helpers'; +import { shouldShowLegendActionDefault } from '../helpers/visualization'; export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) => { const layers = appendLayerIds(args.layers ?? [], 'layers'); @@ -66,6 +67,7 @@ export const layeredXyVisFn: LayeredXyVisFn['fn'] = async (data, args, handlers) syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, overrides: handlers.variables?.overrides as XYRender['value']['overrides'], + shouldShowLegendAction: handlers?.shouldShowLegendAction ?? shouldShowLegendActionDefault, }, }; }; diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts index 9a71ec92d7a513..e0c825597d3287 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis.test.ts @@ -10,6 +10,7 @@ import { xyVisFunction } from '.'; import { createMockExecutionContext } from '@kbn/expressions-plugin/common/mocks'; import { sampleArgs, sampleLayer } from '../__mocks__'; import { XY_VIS } from '../constants'; +import { shouldShowLegendActionDefault } from '../helpers/visualization'; describe('xyVis', () => { test('it renders with the specified data and args', async () => { @@ -42,6 +43,7 @@ describe('xyVis', () => { syncColors: false, syncTooltips: false, syncCursor: true, + shouldShowLegendAction: shouldShowLegendActionDefault, }, }); }); @@ -352,6 +354,7 @@ describe('xyVis', () => { syncColors: false, syncTooltips: false, syncCursor: true, + shouldShowLegendAction: shouldShowLegendActionDefault, }, }); }); @@ -401,6 +404,7 @@ describe('xyVis', () => { syncTooltips: false, syncCursor: true, overrides, + shouldShowLegendAction: shouldShowLegendActionDefault, }, }); }); diff --git a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts index 03df575b3c653a..1eb4357e57c845 100644 --- a/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts +++ b/src/plugins/chart_expressions/expression_xy/common/expression_functions/xy_vis_fn.ts @@ -30,6 +30,7 @@ import { validateAxes, } from './validate'; import { logDatatable } from '../utils'; +import { shouldShowLegendActionDefault } from '../helpers/visualization'; const createDataLayer = (args: XYArgs, table: Datatable): DataLayerConfigResult => { const accessors = getAccessors(args, table); @@ -139,6 +140,7 @@ export const xyVisFn: XyVisFn['fn'] = async (data, args, handlers) => { syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, syncCursor: handlers?.isSyncCursorEnabled?.() ?? true, overrides: handlers.variables?.overrides as XYRender['value']['overrides'], + shouldShowLegendAction: handlers?.shouldShowLegendAction ?? shouldShowLegendActionDefault, }, }; }; diff --git a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts index 66d4c11a9f7ae9..8f740c2578d7e4 100644 --- a/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts +++ b/src/plugins/chart_expressions/expression_xy/common/helpers/visualization.ts @@ -19,3 +19,5 @@ export function isTimeChart(layers: CommonXYDataLayerConfigResult[]) { (!l.xScaleType || l.xScaleType === XScaleTypes.TIME) ); } + +export const shouldShowLegendActionDefault = () => true; 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 1398fc64357cb3..47e36adab06f86 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 @@ -203,7 +203,8 @@ describe('getLegendAction', function () { formattedColumns: {}, }, }, - {} + {}, + () => true ); let wrapper: ReactWrapper; 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 f5b00f696d04fc..2ae2c21b8c0d8d 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 @@ -28,6 +28,7 @@ export const getLegendAction = ( fieldFormats: LayersFieldFormats, formattedDatatables: DatatablesWithFormatInfo, titles: LayersAccessorsTitles, + shouldShowLegendAction?: (actionId: string) => boolean, singleTable?: boolean ): LegendAction => React.memo(({ series: [xySeries] }) => { @@ -109,6 +110,7 @@ export const getLegendAction = ( } onFilter={filterHandler} legendCellValueActions={legendCellValueActions} + shouldShowLegendAction={shouldShowLegendAction} /> ); }); 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 9fa488db24357f..aa2db4c4eb47cd 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 @@ -6,11 +6,18 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover, EuiContextMenu } from '@elastic/eui'; +import { + EuiContextMenuPanelDescriptor, + EuiIcon, + EuiPopover, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, +} from '@elastic/eui'; import { useLegendAction } from '@elastic/charts'; import type { CellValueAction } from '../types'; +import { shouldShowLegendActionDefault } from '../../common/helpers/visualization'; export type LegendCellValueActions = Array< Omit & { execute: () => void } @@ -29,57 +36,70 @@ export interface LegendActionPopoverProps { * Compatible actions to be added to the popover actions */ legendCellValueActions?: LegendCellValueActions; + shouldShowLegendAction?: (actionId: string) => boolean; } export const LegendActionPopover: React.FunctionComponent = ({ label, onFilter, legendCellValueActions = [], + shouldShowLegendAction = shouldShowLegendActionDefault, }) => { 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', - title: label, - items: [ - { - name: i18n.translate('expressionXY.legend.filterForValueButtonAriaLabel', { - defaultMessage: 'Filter for', - }), - 'data-test-subj': `legend-${label}-filterIn`, - icon: , - onClick: () => { - setPopoverOpen(false); - onFilter(); - }, + const panels: EuiContextMenuPanelDescriptor[] = useMemo(() => { + const defaultActions = [ + { + id: 'filterIn', + displayName: i18n.translate('expressionXY.legend.filterForValueButtonAriaLabel', { + defaultMessage: 'Filter for', + }), + 'data-test-subj': `legend-${label}-filterIn`, + iconType: 'plusInCircle', + execute: () => { + setPopoverOpen(false); + onFilter(); }, - { - name: i18n.translate('expressionXY.legend.filterOutValueButtonAriaLabel', { - defaultMessage: 'Filter out', - }), - 'data-test-subj': `legend-${label}-filterOut`, - icon: , + }, + { + id: 'filterOut', + displayName: i18n.translate('expressionXY.legend.filterOutValueButtonAriaLabel', { + defaultMessage: 'Filter out', + }), + 'data-test-subj': `legend-${label}-filterOut`, + iconType: 'minusInCircle', + execute: () => { + setPopoverOpen(false); + onFilter({ negate: true }); + }, + }, + ]; + + const legendCellValueActionPanelItems = [...defaultActions, ...legendCellValueActions].reduce< + EuiContextMenuPanelItemDescriptor[] + >((acc, action) => { + if (shouldShowLegendAction(action.id)) { + acc.push({ + name: action.displayName, + 'data-test-subj': `legend-${label}-${action.id}`, + icon: , onClick: () => { + action.execute(); setPopoverOpen(false); - onFilter({ negate: true }); }, - }, - ...legendCellValueActionPanelItems, - ], - }, - ]; + }); + } + return acc; + }, []); + return [ + { + id: 'main', + title: label, + items: legendCellValueActionPanelItems, + }, + ]; + }, [label, legendCellValueActions, onFilter, shouldShowLegendAction]); const Button = (
& { renderComplete: () => void; uiState?: PersistedState; timeFormat: string; + shouldShowLegendAction?: (actionId: string) => boolean; }; function nonNullable(v: T): v is NonNullable { @@ -207,6 +208,7 @@ export function XYChart({ uiState, timeFormat, overrides, + shouldShowLegendAction, }: XYChartRenderProps) { const { legend, @@ -839,6 +841,7 @@ export function XYChart({ fieldFormats, formattedDatatables, titles, + shouldShowLegendAction, singleTable ) : undefined 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 c2561191deb9af..401af740375b24 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 @@ -277,6 +277,7 @@ export const getXyChartRenderer = ({ syncCursor={config.syncCursor} uiState={handlers.uiState as PersistedState} renderComplete={renderComplete} + shouldShowLegendAction={handlers.shouldShowLegendAction} />
diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index dddc503285942b..eed9628444cc70 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -84,6 +84,8 @@ export interface ExecutionContext< * Logs datatable. */ logDatatable?(name: string, datatable: Datatable): void; + + shouldShowLegendAction?: (actionId: string) => boolean; } /** diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 7dae307aa6c019..e75e0af849ed35 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -105,4 +105,5 @@ export interface IInterpreterRenderHandlers { uiState?: unknown; getExecutionContext(): KibanaExecutionContext | undefined; + shouldShowLegendAction?: (actionId: string) => boolean; } diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index f10b8db1f1287d..c3d7b1fb9920da 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -63,6 +63,7 @@ export class ExpressionLoader { hasCompatibleActions: params?.hasCompatibleActions, getCompatibleCellValueActions: params?.getCompatibleCellValueActions, executionContext: params?.executionContext, + shouldShowLegendAction: params?.shouldShowLegendAction, }); this.render$ = this.renderHandler.render$; this.update$ = this.renderHandler.update$; diff --git a/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx index 1d479bd9b4c1c9..7c299e1bc7240b 100644 --- a/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer/react_expression_renderer.tsx @@ -24,6 +24,7 @@ export interface ReactExpressionRendererProps error?: ExpressionRenderError | null ) => React.ReactElement | React.ReactElement[]; padding?: 'xs' | 's' | 'm' | 'l' | 'xl'; + shouldShowLegendAction?: (actionId: string) => boolean; } export type ReactExpressionRendererType = React.ComponentType; diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index a7b919625b8d66..6bb9c4d0836ba5 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -36,6 +36,7 @@ export interface ExpressionRenderHandlerParams { hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; getCompatibleCellValueActions?: (data: object[]) => Promise; executionContext?: KibanaExecutionContext; + shouldShowLegendAction?: (actionId: string) => boolean; } type UpdateValue = IInterpreterRenderUpdateParams; @@ -66,6 +67,7 @@ export class ExpressionRenderHandler { hasCompatibleActions = async () => false, getCompatibleCellValueActions = async () => [], executionContext, + shouldShowLegendAction, }: ExpressionRenderHandlerParams = {} ) { this.element = element; @@ -118,6 +120,9 @@ export class ExpressionRenderHandler { }, hasCompatibleActions, getCompatibleCellValueActions, + shouldShowLegendAction: (actionId: string) => { + return shouldShowLegendAction?.(actionId) ?? true; + }, }; } diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 870b44e9bc02c9..a96c0629ce8a3c 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -67,6 +67,7 @@ export interface IExpressionLoaderParams { * By default, it equals 1000. */ throttle?: number; + shouldShowLegendAction?: (actionId: string) => boolean; } export interface ExpressionRenderError extends Error { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index 1817a3eaa81750..75a869e6173272 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -176,6 +176,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput { onTableRowClick?: ( data: Simplify ) => void; + shouldShowLegendAction?: (actionId: string) => boolean; } export type LensByValueInput = { @@ -1103,6 +1104,7 @@ export class Embeddable }} noPadding={this.visDisplayOptions.noPadding} docLinks={this.deps.coreStart.docLinks} + shouldShowLegendAction={input.shouldShowLegendAction} /> boolean; } export function ExpressionWrapper({ @@ -73,6 +74,7 @@ export function ExpressionWrapper({ lensInspector, noPadding, docLinks, + shouldShowLegendAction, }: ExpressionWrapperProps) { if (!expression) return null; return ( @@ -104,6 +106,7 @@ export function ExpressionWrapper({ onEvent={handleEvent} hasCompatibleActions={hasCompatibleActions} getCompatibleCellValueActions={getCompatibleCellValueActions} + shouldShowLegendAction={shouldShowLegendAction} /> diff --git a/x-pack/plugins/security_solution/public/actions/filter/index.ts b/x-pack/plugins/security_solution/public/actions/filter/index.ts index 3eadceb7d55b95..2759d5cc20d36c 100644 --- a/x-pack/plugins/security_solution/public/actions/filter/index.ts +++ b/x-pack/plugins/security_solution/public/actions/filter/index.ts @@ -9,3 +9,7 @@ export { createFilterInCellActionFactory } from './cell_action/filter_in'; export { createFilterOutCellActionFactory } from './cell_action/filter_out'; export { createFilterInDiscoverCellActionFactory } from './discover/filter_in'; export { createFilterOutDiscoverCellActionFactory } from './discover/filter_out'; +export { createTimelineHistogramFilterInLegendActionFactory } from './lens/filter_in_timeline'; +export { createFilterInHistogramLegendActionFactory } from './lens/filter_in'; +export { createTimelineHistogramFilterOutLegendActionFactory } from './lens/filter_out_timeline'; +export { createFilterOutHistogramLegendActionFactory } from './lens/filter_out'; diff --git a/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in.ts b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in.ts new file mode 100644 index 00000000000000..aee91a849d898b --- /dev/null +++ b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in.ts @@ -0,0 +1,29 @@ +/* + * 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 { SecurityAppStore } from '../../../common/store'; + +import type { StartServices } from '../../../types'; +import { createHistogramFilterLegendActionFactory } from './helpers'; + +export const HISTOGRAM_LEGEND_ACTION_FILTER_IN = 'histogramLegendActionFilterIn'; + +export const createFilterInHistogramLegendActionFactory = ({ + store, + order, + services, +}: { + store: SecurityAppStore; + order: number; + services: StartServices; +}) => + createHistogramFilterLegendActionFactory({ + id: HISTOGRAM_LEGEND_ACTION_FILTER_IN, + order, + store, + services, + }); diff --git a/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in_timeline.ts b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in_timeline.ts new file mode 100644 index 00000000000000..6721972f0bcfb8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_in_timeline.ts @@ -0,0 +1,29 @@ +/* + * 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 { SecurityAppStore } from '../../../common/store'; + +import type { StartServices } from '../../../types'; +import { createHistogramFilterLegendActionFactory } from './helpers'; + +export const TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_IN = 'timelineHistogramLegendActionFilterIn'; + +export const createTimelineHistogramFilterInLegendActionFactory = ({ + store, + order, + services, +}: { + store: SecurityAppStore; + order: number; + services: StartServices; +}) => + createHistogramFilterLegendActionFactory({ + id: TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_IN, + order, + store, + services, + }); diff --git a/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out.ts b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out.ts new file mode 100644 index 00000000000000..4e32a3bee1b1f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out.ts @@ -0,0 +1,30 @@ +/* + * 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 { SecurityAppStore } from '../../../common/store'; + +import type { StartServices } from '../../../types'; +import { createHistogramFilterLegendActionFactory } from './helpers'; + +export const HISTOGRAM_LEGEND_ACTION_FILTER_OUT = 'histogramLegendActionFilterOut'; + +export const createFilterOutHistogramLegendActionFactory = ({ + store, + order, + services, +}: { + store: SecurityAppStore; + order: number; + services: StartServices; +}) => + createHistogramFilterLegendActionFactory({ + id: HISTOGRAM_LEGEND_ACTION_FILTER_OUT, + order, + store, + services, + negate: true, + }); diff --git a/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out_timeline.ts b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out_timeline.ts new file mode 100644 index 00000000000000..1712c94c21b791 --- /dev/null +++ b/x-pack/plugins/security_solution/public/actions/filter/lens/filter_out_timeline.ts @@ -0,0 +1,30 @@ +/* + * 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 { SecurityAppStore } from '../../../common/store'; + +import type { StartServices } from '../../../types'; +import { createHistogramFilterLegendActionFactory } from './helpers'; + +export const TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_OUT = 'timelineHistogramLegendActionFilterOut'; + +export const createTimelineHistogramFilterOutLegendActionFactory = ({ + store, + order, + services, +}: { + store: SecurityAppStore; + order: number; + services: StartServices; +}) => + createHistogramFilterLegendActionFactory({ + id: TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_OUT, + order, + store, + services, + negate: true, + }); diff --git a/x-pack/plugins/security_solution/public/actions/filter/lens/helpers.ts b/x-pack/plugins/security_solution/public/actions/filter/lens/helpers.ts new file mode 100644 index 00000000000000..d138561aab1b63 --- /dev/null +++ b/x-pack/plugins/security_solution/public/actions/filter/lens/helpers.ts @@ -0,0 +1,111 @@ +/* + * 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 { addFilterIn, addFilterOut } from '@kbn/cell-actions'; +import { + isValueSupportedByDefaultActions, + valueToArray, + filterOutNullableValues, +} from '@kbn/cell-actions/src/actions/utils'; +import { isErrorEmbeddable } from '@kbn/embeddable-plugin/public'; +import type { CellValueContext } from '@kbn/embeddable-plugin/public'; +import { createAction } from '@kbn/ui-actions-plugin/public'; +import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations'; +import { i18n } from '@kbn/i18n'; +import { KibanaServices } from '../../../common/lib/kibana'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { fieldHasCellActions, isInSecurityApp, isLensEmbeddable } from '../../utils'; +import { TimelineId } from '../../../../common/types'; +import { SecurityCellActionType } from '../../constants'; +import type { SecurityAppStore } from '../../../common/store'; +import type { StartServices } from '../../../types'; +import { HISTOGRAM_LEGEND_ACTION_FILTER_IN } from './filter_in'; +import { HISTOGRAM_LEGEND_ACTION_FILTER_OUT } from './filter_out'; + +function isDataColumnsValid(data?: CellValueContext['data']): boolean { + return ( + !!data && + data.length > 0 && + data.every(({ columnMeta }) => columnMeta && fieldHasCellActions(columnMeta.field)) + ); +} + +export const createHistogramFilterLegendActionFactory = ({ + id, + order, + store, + services, + negate, +}: { + id: string; + order: number; + store: SecurityAppStore; + services: StartServices; + negate?: boolean; +}) => { + const { application: applicationService } = KibanaServices.get(); + let currentAppId: string | undefined; + applicationService.currentAppId$.subscribe((appId) => { + currentAppId = appId; + }); + const getTimelineById = timelineSelectors.getTimelineByIdSelector(); + const { notifications } = services; + const { filterManager } = services.data.query; + + return createAction({ + id, + order, + getIconType: () => (negate ? 'minusInCircle' : 'plusInCircle'), + getDisplayName: () => + negate + ? i18n.translate('xpack.securitySolution.actions.filterOutTimeline', { + defaultMessage: `Filter out`, + }) + : i18n.translate('xpack.securitySolution.actions.filterForTimeline', { + defaultMessage: `Filter for`, + }), + type: SecurityCellActionType.FILTER, + isCompatible: async ({ embeddable, data }) => + !isErrorEmbeddable(embeddable) && + isLensEmbeddable(embeddable) && + isDataColumnsValid(data) && + isInSecurityApp(currentAppId), + execute: async ({ data }) => { + const field = data[0]?.columnMeta?.field; + const rawValue = data[0]?.value; + const value = filterOutNullableValues(valueToArray(rawValue)); + + if (!isValueSupportedByDefaultActions(value)) { + notifications.toasts.addWarning({ + title: ACTION_INCOMPATIBLE_VALUE_WARNING, + }); + return; + } + + if (!field) return; + + const timeline = getTimelineById(store.getState(), TimelineId.active); + services.topValuesPopover.closePopover(); + + if (!negate) { + addFilterIn({ + filterManager: + id === HISTOGRAM_LEGEND_ACTION_FILTER_IN ? filterManager : timeline.filterManager, + fieldName: field, + value, + }); + } else { + addFilterOut({ + filterManager: + id === HISTOGRAM_LEGEND_ACTION_FILTER_OUT ? filterManager : timeline.filterManager, + fieldName: field, + value, + }); + } + }, + }); +}; diff --git a/x-pack/plugins/security_solution/public/actions/register.ts b/x-pack/plugins/security_solution/public/actions/register.ts index 6f154fba11b219..5aa0794ae9c49a 100644 --- a/x-pack/plugins/security_solution/public/actions/register.ts +++ b/x-pack/plugins/security_solution/public/actions/register.ts @@ -13,8 +13,12 @@ import type { StartServices } from '../types'; import { createFilterInCellActionFactory, createFilterInDiscoverCellActionFactory, + createTimelineHistogramFilterInLegendActionFactory, + createFilterInHistogramLegendActionFactory, createFilterOutCellActionFactory, createFilterOutDiscoverCellActionFactory, + createFilterOutHistogramLegendActionFactory, + createTimelineHistogramFilterOutLegendActionFactory, } from './filter'; import { createAddToTimelineLensAction, @@ -53,11 +57,39 @@ export const registerUIActions = ( const registerLensEmbeddableActions = (store: SecurityAppStore, services: StartServices) => { const { uiActions } = services; - const addToTimelineAction = createAddToTimelineLensAction({ store, order: 1 }); + const addToTimelineAction = createAddToTimelineLensAction({ store, order: 4 }); uiActions.addTriggerAction(CELL_VALUE_TRIGGER, addToTimelineAction); - const copyToClipboardAction = createCopyToClipboardLensAction({ order: 2 }); + const copyToClipboardAction = createCopyToClipboardLensAction({ order: 5 }); uiActions.addTriggerAction(CELL_VALUE_TRIGGER, copyToClipboardAction); + + const filterInTimelineLegendActions = createTimelineHistogramFilterInLegendActionFactory({ + store, + order: 0, + services, + }); + uiActions.addTriggerAction(CELL_VALUE_TRIGGER, filterInTimelineLegendActions); + + const filterOutTimelineLegendActions = createTimelineHistogramFilterOutLegendActionFactory({ + store, + order: 1, + services, + }); + uiActions.addTriggerAction(CELL_VALUE_TRIGGER, filterOutTimelineLegendActions); + + const filterInLegendActions = createFilterInHistogramLegendActionFactory({ + store, + order: 2, + services, + }); + uiActions.addTriggerAction(CELL_VALUE_TRIGGER, filterInLegendActions); + + const filterOutLegendActions = createFilterOutHistogramLegendActionFactory({ + store, + order: 3, + services, + }); + uiActions.addTriggerAction(CELL_VALUE_TRIGGER, filterOutLegendActions); }; const registerDiscoverCellActions = (store: SecurityAppStore, services: StartServices) => { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx index 943bf4dab0d80f..df564277122cec 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/lens_embeddable.tsx @@ -24,7 +24,7 @@ import { inputsSelectors } from '../../store'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { ModalInspectQuery } from '../inspect/modal'; import { InputsModelId } from '../../store/inputs/constants'; -import { getRequestsAndResponses } from './utils'; +import { getRequestsAndResponses, showLegendActionsByActionId } from './utils'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { VisualizationActions } from './actions'; @@ -218,6 +218,11 @@ const LensEmbeddableComponent: React.FC = ({ [attributes?.state?.adHocDataViews] ); + const shouldShowLegendAction = useCallback( + (actionId: string) => showLegendActionsByActionId({ actionId, scopeId }), + [scopeId] + ); + if (!searchSessionId) { return null; } @@ -281,6 +286,7 @@ const LensEmbeddableComponent: React.FC = ({ showInspector={false} syncTooltips={false} syncCursor={false} + shouldShowLegendAction={shouldShowLegendAction} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts b/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts index a9344782181111..678cc16c915ef1 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/utils.ts @@ -6,10 +6,17 @@ */ import type { Filter } from '@kbn/es-query'; + import { SecurityPageName } from '../../../../common/constants'; +import { HISTOGRAM_LEGEND_ACTION_FILTER_IN } from '../../../actions/filter/lens/filter_in'; +import { TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_IN } from '../../../actions/filter/lens/filter_in_timeline'; +import { HISTOGRAM_LEGEND_ACTION_FILTER_OUT } from '../../../actions/filter/lens/filter_out'; +import { TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_OUT } from '../../../actions/filter/lens/filter_out_timeline'; import type { Request } from './types'; export const VISUALIZATION_ACTIONS_BUTTON_CLASS = 'histogram-actions-trigger'; +export const FILTER_IN_LEGEND_ACTION = `filterIn`; +export const FILTER_OUT_LEGEND_ACTION = `filterOut`; const pageFilterFieldMap: Record = { [SecurityPageName.hosts]: 'host', @@ -192,3 +199,28 @@ export const parseVisualizationData = (data: string[]): T[] => return acc; } }, [] as T[]); + +export const showLegendActionsByActionId = ({ + actionId, + scopeId, +}: { + actionId: string; + scopeId: string; +}) => { + switch (actionId) { + /** We no longer use Lens' default filter in / out actions + * as extra custom actions needed after filters applied. + * For example: hide the topN panel after filters applied */ + case FILTER_IN_LEGEND_ACTION: + case FILTER_OUT_LEGEND_ACTION: + return false; + case HISTOGRAM_LEGEND_ACTION_FILTER_IN: + case HISTOGRAM_LEGEND_ACTION_FILTER_OUT: + return scopeId !== 'timeline'; + case TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_IN: + case TIMELINE_HISTOGRAM_LEGEND_ACTION_FILTER_OUT: + return scopeId === 'timeline'; + default: + return true; + } +}; diff --git a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts index d0681a4348e064..f616b63e45e19d 100644 --- a/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts +++ b/x-pack/test/security_solution_cypress/cypress/screens/alerts.ts @@ -140,8 +140,10 @@ export const SELECT_HISTOGRAM = '[data-test-subj="chart-select-trend"]'; export const LEGEND_ACTIONS = { ADD_TO_TIMELINE: (ruleName: string) => `[data-test-subj="legend-${ruleName}-embeddable_addToTimeline"]`, - FILTER_FOR: (ruleName: string) => `[data-test-subj="legend-${ruleName}-filterIn"]`, - FILTER_OUT: (ruleName: string) => `[data-test-subj="legend-${ruleName}-filterOut"]`, + FILTER_FOR: (ruleName: string) => + `[data-test-subj="legend-${ruleName}-histogramLegendActionFilterIn"]`, + FILTER_OUT: (ruleName: string) => + `[data-test-subj="legend-${ruleName}-histogramLegendActionFilterOut"]`, COPY: (ruleName: string) => `[data-test-subj="legend-${ruleName}-embeddable_copyToClipboard"]`, };