From f24e4ea0da4953c790bebf6aa391083d01940375 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Thu, 5 Aug 2021 06:44:08 -0600 Subject: [PATCH] ## Summary This PR implements cell actions in the `TGrid`, rendering them via `EuiDataGrid`, per the screenshots below: ### Before Users previously hovered over a draggable field to view and trigger cell actions, as illustrated by the `Before` screenshots below: legacy_cell_actions _Above: legacy `TGrid` cell action rendering_ ### After Cell actions are now rendered via `EuiDataGrid` cell actions: euidatagrid_cell_actions _Above: new `TGrid` cell action rendering via `EuiDataGrid`_ ## Technical Details Every instance of the `TGrid` on a page can specify its own set of cell actions via `defaultCellActions` when calling the `timelines.getTGrid()` function create an instance. For example, the Observability Alerts `TGrid` is initialized in with a default set of actions in `x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx`, as shown in the code below: ```ts {timelines.getTGrid<'standalone'>({ type: 'standalone', columns, deletedEventIds: [], defaultCellActions: getDefaultCellActions({ enableFilterActions: false }), // <-- defaultCellActions // ... ``` The type of the `defaultCellActions` is: ```ts defaultCellActions?: TGridCellAction[]; ``` and the definition of `TGridCellAction` is in `x-pack/plugins/timelines/common/types/timeline/columns/index.tsx`: ```ts /** * A `TGridCellAction` function accepts `data`, where each row of data is * represented as a `TimelineNonEcsData[]`. For example, `data[0]` would * contain a `TimelineNonEcsData[]` with the first row of data. * * A `TGridCellAction` returns a function that has access to all the * `EuiDataGridColumnCellActionProps`, _plus_ access to `data`, * which enables code like the following example to be written: * * Example: * ``` * ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { * const value = getMappedNonEcsValue({ * data: data[rowIndex], // access a specific row's values * fieldName: columnId, * }); * * return ( * alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart"> * {'Love it'} * * ); * }; * ``` */ export type TGridCellAction = ({ browserFields, data, }: { browserFields: BrowserFields; /** each row of data is represented as one TimelineNonEcsData[] */ data: TimelineNonEcsData[][]; }) => (props: EuiDataGridColumnCellActionProps) => ReactNode; ``` For example, the following `TGridCellAction[]` defines the `Copy to clipboard` action for the Observability Alerts table in `x-pack/plugins/observability/public/pages/alerts/default_cell_actions.tsx`: ```ts /** actions common to all cells (e.g. copy to clipboard) */ const commonCellActions: TGridCellAction[] = [ ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { const { timelines } = useKibanaServices(); const value = getMappedNonEcsValue({ data: data[rowIndex], fieldName: columnId, }); return ( <> {timelines.getHoverActions().getCopyButton({ Component, field: columnId, isHoverAction: false, ownFocus: false, showTooltip: false, value, })} ); }, ]; ``` Note that an _implementation_ of the copy action, including the button, is available for both the Observability and Security solutions to use via `timelines.getHoverActions().getCopyButton()`, (and both solutions use it in this PR), but there's no requirement to use that specific implementation of the copy action. ### Security Solution cell actions All previously-available hover actions in the Security Solution are now available as cell actions, i.e.: - Filter for value - Filter out value - Add to timeline investigation - Show Top `` (only enabled for some data types) - Copy to clipboard ### Observability cell actions In this PR: - Only the `Copy to clipboard` cell action is enabled by default in the Observability Alerts table - The `Filter for value` and `Filter out value` cell actions may be enabled in the `Observability` solution by changing a single line of code, (setting `enableFilterActions` to true), on the following line in `x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx`: ```js defaultCellActions: getDefaultCellActions({ enableFilterActions: false }), // <-- set this to `true` to enable the filter actions ``` `enableFilterActions` is set to `false` in this PR because the Observability Alerts page's search bar, defined in `x-pack/plugins/observability/public/pages/alerts/alerts_search_bar.tsx`: ```ts return ( 75', })} query={{ query: query ?? '', language: queryLanguage }} // ... /> ```` must be integrated with a `filterManager` to display the filters. A `filterManager` instance may be obtained in the Observability solution via the following boilerplate: ```ts const { services: { data: { query: { filterManager }, }, }, } = useKibana(); ``` ## Desk testing To desk test this PR, you must enable feature flags in the Observability and Security Solution: - To desk test the `Observability > Alerts` page, add the following settings to `config/kibana.dev.yml`: ``` xpack.observability.unsafe.cases.enabled: true xpack.observability.unsafe.alertingExperience.enabled: true xpack.ruleRegistry.write.enabled: true ``` - To desk test the TGrid in the following Security Solution, edit `x-pack/plugins/security_solution/common/experimental_features.ts` and in the `allowedExperimentalValues` section set: ```typescript tGridEnabled: true, ``` cc @mdefazio --- .../pages/alerts/alerts_table_t_grid.tsx | 2 + .../pages/alerts/default_cell_actions.tsx | 109 +++++++++++ .../public/pages/alerts/render_cell_value.tsx | 2 +- .../components/alerts_viewer/alerts_table.tsx | 2 + .../events_viewer/events_viewer.test.tsx | 2 + .../components/events_viewer/index.test.tsx | 2 + .../common/components/events_viewer/index.tsx | 5 + .../hover_actions/actions/show_top_n.tsx | 56 ++++-- .../lib/cell_actions/default_cell_actions.tsx | 178 ++++++++++++++++++ .../components/alerts_table/index.tsx | 2 + .../navigation/events_query_tab_body.tsx | 2 + .../timeline/columns/{index.ts => index.tsx} | 40 +++- .../components/clipboard/translations.ts | 6 + .../hover_actions/actions/add_to_timeline.tsx | 54 ++++-- .../components/hover_actions/actions/copy.tsx | 38 +++- .../actions/filter_for_value.tsx | 50 +++-- .../actions/filter_out_value.tsx | 50 +++-- .../components/hover_actions/actions/types.ts | 4 +- .../public/components/t_grid/body/index.tsx | 24 ++- .../components/t_grid/integrated/index.tsx | 5 +- .../components/t_grid/standalone/index.tsx | 5 +- 21 files changed, 556 insertions(+), 82 deletions(-) create mode 100644 x-pack/plugins/observability/public/pages/alerts/default_cell_actions.tsx create mode 100644 x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx rename x-pack/plugins/timelines/common/types/timeline/columns/{index.ts => index.tsx} (52%) diff --git a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx index cd8965de36f1c0..aecc4ea03670c9 100644 --- a/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/alerts_table_t_grid.tsx @@ -31,6 +31,7 @@ import type { import { getRenderCellValue } from './render_cell_value'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { decorateResponse } from './decorate_response'; +import { getDefaultCellActions } from './default_cell_actions'; import { LazyAlertsFlyout } from '../..'; interface AlertsTableTGridProps { @@ -192,6 +193,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) { type: 'standalone', columns, deletedEventIds: [], + defaultCellActions: getDefaultCellActions({ enableFilterActions: false }), end: rangeTo, filters: [], indexNames: [indexName], diff --git a/x-pack/plugins/observability/public/pages/alerts/default_cell_actions.tsx b/x-pack/plugins/observability/public/pages/alerts/default_cell_actions.tsx new file mode 100644 index 00000000000000..3056b026fc27ae --- /dev/null +++ b/x-pack/plugins/observability/public/pages/alerts/default_cell_actions.tsx @@ -0,0 +1,109 @@ +/* + * 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 { ObservabilityPublicPluginsStart } from '../..'; +import { getMappedNonEcsValue } from './render_cell_value'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { TimelineNonEcsData } from '../../../../timelines/common/search_strategy'; +import { TGridCellAction } from '../../../../timelines/common/types/timeline'; +import { TimelinesUIStart } from '../../../../timelines/public'; + +/** a noop required by the filter in / out buttons */ +const onFilterAdded = () => {}; + +/** a hook to eliminate the verbose boilerplate required to use common services */ +const useKibanaServices = () => { + const { timelines } = useKibana<{ timelines: TimelinesUIStart }>().services; + const { + services: { + data: { + query: { filterManager }, + }, + }, + } = useKibana(); + + return { timelines, filterManager }; +}; + +/** actions for adding filters to the search bar */ +const filterCellActions: TGridCellAction[] = [ + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines, filterManager } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getFilterForValueButton({ + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines, filterManager } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getFilterOutValueButton({ + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, +]; + +/** actions common to all cells (e.g. copy to clipboard) */ +const commonCellActions: TGridCellAction[] = [ + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getCopyButton({ + Component, + field: columnId, + isHoverAction: false, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, +]; + +/** returns the default actions shown in `EuiDataGrid` cells */ +export const getDefaultCellActions = ({ enableFilterActions }: { enableFilterActions: boolean }) => + enableFilterActions ? [...filterCellActions, ...commonCellActions] : [...commonCellActions]; diff --git a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx index bb6d0ae6c0e407..cac3240cd20048 100644 --- a/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx +++ b/x-pack/plugins/observability/public/pages/alerts/render_cell_value.tsx @@ -23,7 +23,7 @@ import { TopAlert } from '.'; import { decorateResponse } from './decorate_response'; import { usePluginContext } from '../../hooks/use_plugin_context'; -const getMappedNonEcsValue = ({ +export const getMappedNonEcsValue = ({ data, fieldName, }: { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 2a3484318966f9..f906d1cac0153f 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -15,6 +15,7 @@ import { alertsDefaultModel } from './default_headers'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import * as i18n from './translations'; +import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; import { useKibana } from '../../lib/kibana'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; @@ -104,6 +105,7 @@ const AlertsTableComponent: React.FC = ({ { const mount = useMountAppended(); let testProps = { + defaultCellActions, defaultModel: eventsDefaultModel, end: to, id: TimelineId.test, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx index 571e04a106cf0b..743c1894db2e3b 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.test.tsx @@ -21,6 +21,7 @@ import { SourcererScopeName } from '../../store/sourcerer/model'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { useTimelineEvents } from '../../../timelines/containers'; import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions'; jest.mock('../../../common/lib/kibana'); @@ -38,6 +39,7 @@ const from = '2019-08-27T22:10:56.794Z'; const to = '2019-08-26T22:10:56.791Z'; const testProps = { + defaultCellActions, defaultModel: eventsDefaultModel, end: to, indexNames: [], diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 4b91122103d16a..8a8ebd18174be4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -23,6 +23,7 @@ import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; +import { TGridCellAction } from '../../../../../timelines/common/types'; import { DetailsPanel } from '../../../timelines/components/side_panel'; import { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { useKibana } from '../../lib/kibana'; @@ -47,6 +48,7 @@ const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>` `; export interface OwnProps { + defaultCellActions?: TGridCellAction[]; defaultModel: SubsetTimelineModel; end: string; id: TimelineId; @@ -73,6 +75,7 @@ const StatefulEventsViewerComponent: React.FC = ({ createTimeline, columns, dataProviders, + defaultCellActions, deletedEventIds, deleteEventQuery, end, @@ -140,6 +143,7 @@ const StatefulEventsViewerComponent: React.FC = ({ browserFields, columns, dataProviders: dataProviders!, + defaultCellActions, deletedEventIds, docValueFields, end, @@ -269,6 +273,7 @@ export const StatefulEventsViewer = connector( prevProps.scopeId === nextProps.scopeId && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && + prevProps.defaultCellActions === nextProps.defaultCellActions && deepEqual(prevProps.excludedRowRendererIds, nextProps.excludedRowRendererIds) && prevProps.deletedEventIds === nextProps.deletedEventIds && prevProps.end === nextProps.end && diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx index 0fc8a740845213..89505cfeca75a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx @@ -6,7 +6,7 @@ */ import React, { useMemo } from 'react'; -import { EuiButtonIcon, EuiPopover, EuiToolTip } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButtonIcon, EuiPopover, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { StatefulTopN } from '../../top_n'; import { TimelineId } from '../../../../../common/types/timeline'; @@ -23,17 +23,30 @@ const SHOW_TOP = (fieldName: string) => }); interface Props { + /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ + Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; field: string; onClick: () => void; onFilterAdded?: () => void; ownFocus: boolean; showTopN: boolean; + showTooltip?: boolean; timelineId?: string | null; value?: string[] | string | null; } export const ShowTopNButton: React.FC = React.memo( - ({ field, onClick, onFilterAdded, ownFocus, showTopN, timelineId, value }) => { + ({ + Component, + field, + onClick, + onFilterAdded, + ownFocus, + showTooltip = true, + showTopN, + timelineId, + value, + }) => { const activeScope: SourcererScopeName = timelineId === TimelineId.active ? SourcererScopeName.timeline @@ -44,19 +57,32 @@ export const ShowTopNButton: React.FC = React.memo( ? SourcererScopeName.detections : SourcererScopeName.default; const { browserFields, indexPattern } = useSourcererScope(activeScope); + const button = useMemo( - () => ( - - ), - [field, onClick] + () => + Component ? ( + + {SHOW_TOP(field)} + + ) : ( + + ), + [Component, field, onClick] ); + return showTopN ? ( = React.memo( value={value} /> - ) : ( + ) : showTooltip ? ( = React.memo( > {button} + ) : ( + button ); } ); diff --git a/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx new file mode 100644 index 00000000000000..951d921653c150 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/cell_actions/default_cell_actions.tsx @@ -0,0 +1,178 @@ +/* + * 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, { useCallback, useState, useMemo } from 'react'; + +import type { + BrowserFields, + TimelineNonEcsData, +} from '../../../../../timelines/common/search_strategy'; +import { DataProvider, TGridCellAction } from '../../../../../timelines/common/types'; +import { TimelineId } from '../../../../common'; +import { getMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { allowTopN, escapeDataProviderId } from '../../components/drag_and_drop/helpers'; +import { ShowTopNButton } from '../../components/hover_actions/actions/show_top_n'; +import { getAllFieldsByName } from '../../containers/source'; +import { useKibana } from '../kibana'; + +/** a noop required by the filter in / out buttons */ +const onFilterAdded = () => {}; + +/** a hook to eliminate the verbose boilerplate required to use common services */ +const useKibanaServices = () => { + const { + timelines, + data: { + query: { filterManager }, + }, + } = useKibana().services; + + return { timelines, filterManager }; +}; + +/** the default actions shown in `EuiDataGrid` cells */ +export const defaultCellActions: TGridCellAction[] = [ + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines, filterManager } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getFilterForValueButton({ + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines, filterManager } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getFilterOutValueButton({ + Component, + field: columnId, + filterManager, + onFilterAdded, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + const dataProvider: DataProvider[] = useMemo( + () => + value?.map((x) => ({ + and: [], + enabled: true, + id: `${escapeDataProviderId(columnId)}-row-${rowIndex}-col-${columnId}-val-${x}`, + name: x, + excluded: false, + kqlQuery: '', + queryMatch: { + field: columnId, + value: x, + operator: IS_OPERATOR, + }, + })) ?? [], + [columnId, rowIndex, value] + ); + + return ( + <> + {timelines.getHoverActions().getAddToTimelineButton({ + Component, + dataProvider, + field: columnId, + ownFocus: false, + showTooltip: false, + })} + + ); + }, + ({ browserFields, data }: { browserFields: BrowserFields; data: TimelineNonEcsData[][] }) => ({ + rowIndex, + columnId, + Component, + }) => { + const [showTopN, setShowTopN] = useState(false); + const onClick = useCallback(() => setShowTopN(!showTopN), [showTopN]); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {allowTopN({ + browserField: getAllFieldsByName(browserFields)[columnId], + fieldName: columnId, + }) && ( + + )} + + ); + }, + ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => { + const { timelines } = useKibanaServices(); + + const value = getMappedNonEcsValue({ + data: data[rowIndex], + fieldName: columnId, + }); + + return ( + <> + {timelines.getHoverActions().getCopyButton({ + Component, + field: columnId, + isHoverAction: false, + ownFocus: false, + showTooltip: false, + value, + })} + + ); + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 4b91e3b1e35fce..a1f2025c6c0d5d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -53,6 +53,7 @@ import { defaultRowRenderers } from '../../../timelines/components/timeline/body import { columns, RenderCellValue } from '../../configurations/security_solution_detections'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; +import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions'; interface OwnProps { defaultFilters?: Filter[]; @@ -388,6 +389,7 @@ export const AlertsTableComponent: React.FC = ({ return ( = ({ /> )} ({ rowIndex, columnId, Component }) => { + * const value = getMappedNonEcsValue({ + * data: data[rowIndex], // access a specific row's values + * fieldName: columnId, + * }); + * + * return ( + * alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart"> + * {'Love it'} + * + * ); + * }; + * ``` + */ +export type TGridCellAction = ({ + browserFields, + data, +}: { + browserFields: BrowserFields; + /** each row of data is represented as one TimelineNonEcsData[] */ + data: TimelineNonEcsData[][]; +}) => (props: EuiDataGridColumnCellActionProps) => ReactNode; + /** The specification of a column header */ export type ColumnHeaderOptions = Pick< EuiDataGridColumn, @@ -26,6 +63,7 @@ export type ColumnHeaderOptions = Pick< | 'isSortable' > & { aggregatable?: boolean; + tGridCellActions?: TGridCellAction[]; category?: string; columnHeaderType: ColumnHeaderType; description?: string; diff --git a/x-pack/plugins/timelines/public/components/clipboard/translations.ts b/x-pack/plugins/timelines/public/components/clipboard/translations.ts index a92c9656f3cf83..25b5ea7bf7b83d 100644 --- a/x-pack/plugins/timelines/public/components/clipboard/translations.ts +++ b/x-pack/plugins/timelines/public/components/clipboard/translations.ts @@ -25,3 +25,9 @@ export const COPY_TO_THE_CLIPBOARD = i18n.translate( defaultMessage: 'Copy to the clipboard', } ); + +export const SUCCESS_TOAST_TITLE = (field: string) => + i18n.translate('xpack.timelines.clipboard.copy.successToastTitle', { + values: { field }, + defaultMessage: 'Copied field {field} to the clipboard', + }); diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/add_to_timeline.tsx b/x-pack/plugins/timelines/public/components/hover_actions/actions/add_to_timeline.tsx index dd5ef27c32a89c..80d413a29e6fca 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/add_to_timeline.tsx +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/add_to_timeline.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { EuiButtonEmpty, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import { DraggableId } from 'react-beautiful-dnd'; import { useDispatch } from 'react-redux'; @@ -44,12 +44,15 @@ const useGetHandleStartDragToTimeline = ({ }; export interface AddToTimelineButtonProps extends HoverActionComponentProps { + /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ + Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; draggableId?: DraggableId; dataProvider?: DataProvider[] | DataProvider; } const AddToTimelineButton: React.FC = React.memo( ({ + Component, closePopOver, dataProvider, defaultFocusedButtonRef, @@ -96,6 +99,33 @@ const AddToTimelineButton: React.FC = React.memo( } }, [handleStartDragToTimeline, keyboardEvent, ownFocus]); + const button = useMemo( + () => + Component ? ( + + {i18n.ADD_TO_TIMELINE} + + ) : ( + + ), + [Component, defaultFocusedButtonRef, handleStartDragToTimeline] + ); + return showTooltip ? ( = React.memo( /> } > - + {button} ) : ( - + button ); } ); diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/copy.tsx b/x-pack/plugins/timelines/public/components/hover_actions/actions/copy.tsx index 1b567dee50683e..c188af67d33fde 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/copy.tsx +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/copy.tsx @@ -5,12 +5,18 @@ * 2.0. */ -import React, { useEffect, useRef } from 'react'; +import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import copy from 'copy-to-clipboard'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; + import { stopPropagationAndPreventDefault } from '../../../../common'; import { WithCopyToClipboard } from '../../clipboard/with_copy_to_clipboard'; import { HoverActionComponentProps } from './types'; import { COPY_TO_CLIPBOARD_BUTTON_CLASS_NAME } from '../../clipboard'; +import { useAppToasts } from '../../../hooks/use_app_toasts'; +import { COPY_TO_CLIPBOARD } from '../../t_grid/body/translations'; +import { SUCCESS_TOAST_TITLE } from '../../clipboard/translations'; export const FIELD = i18n.translate('xpack.timelines.hoverActions.fieldLabel', { defaultMessage: 'Field', @@ -19,11 +25,14 @@ export const FIELD = i18n.translate('xpack.timelines.hoverActions.fieldLabel', { export const COPY_TO_CLIPBOARD_KEYBOARD_SHORTCUT = 'c'; export interface CopyProps extends HoverActionComponentProps { + /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ + Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; isHoverAction?: boolean; } const CopyButton: React.FC = React.memo( - ({ closePopOver, field, isHoverAction, keyboardEvent, ownFocus, value }) => { + ({ Component, closePopOver, field, isHoverAction, keyboardEvent, ownFocus, value }) => { + const { addSuccess } = useAppToasts(); const panelRef = useRef(null); useEffect(() => { if (!ownFocus) { @@ -42,13 +51,34 @@ const CopyButton: React.FC = React.memo( } } }, [closePopOver, keyboardEvent, ownFocus]); - return ( + + const text = useMemo(() => `${field}${value != null ? `: "${value}"` : ''}`, [field, value]); + + const onClick = useCallback(() => { + const isSuccess = copy(text, { debug: true }); + + if (isSuccess) { + addSuccess(SUCCESS_TOAST_TITLE(field), { toastLifeTimeMs: 800 }); + } + }, [addSuccess, field, text]); + + return Component ? ( + + {COPY_TO_CLIPBOARD} + + ) : (
diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_for_value.tsx b/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_for_value.tsx index 58f7b4a831e513..6ba55f50560eba 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_for_value.tsx +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_for_value.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; @@ -23,6 +23,7 @@ export type FilterForValueProps = HoverActionComponentProps & FilterValueFnArgs; const FilterForValueButton: React.FC = React.memo( ({ + Component, closePopOver, defaultFocusedButtonRef, field, @@ -63,6 +64,33 @@ const FilterForValueButton: React.FC = React.memo( } }, [filterForValueFn, keyboardEvent, ownFocus]); + const button = useMemo( + () => + Component ? ( + + {FILTER_FOR_VALUE} + + ) : ( + + ), + [Component, defaultFocusedButtonRef, filterForValueFn] + ); + return showTooltip ? ( = React.memo( /> } > - + {button} ) : ( - + button ); } ); diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_out_value.tsx b/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_out_value.tsx index 03150d6371397b..c19f4febf49d98 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_out_value.tsx +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/filter_out_value.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; @@ -22,6 +22,7 @@ export const FILTER_OUT_VALUE_KEYBOARD_SHORTCUT = 'o'; const FilterOutValueButton: React.FC = React.memo( ({ + Component, closePopOver, defaultFocusedButtonRef, field, @@ -64,6 +65,33 @@ const FilterOutValueButton: React.FC + Component ? ( + + {FILTER_OUT_VALUE} + + ) : ( + + ), + [Component, defaultFocusedButtonRef, filterOutValueFn] + ); + return showTooltip ? ( } > - + {button} ) : ( - + button ); } ); diff --git a/x-pack/plugins/timelines/public/components/hover_actions/actions/types.ts b/x-pack/plugins/timelines/public/components/hover_actions/actions/types.ts index fdef1403e3dc2f..06069ede15b985 100644 --- a/x-pack/plugins/timelines/public/components/hover_actions/actions/types.ts +++ b/x-pack/plugins/timelines/public/components/hover_actions/actions/types.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { EuiButtonIconPropsForButton } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButtonIcon, EuiButtonIconPropsForButton } from '@elastic/eui'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; export interface FilterValueFnArgs { + /** `Component` is only used with `EuiDataGrid`; the grid keeps a reference to `Component` for show / hide functionality */ + Component?: typeof EuiButtonEmpty | typeof EuiButtonIcon; field: string; value: string[] | string | null | undefined; filterManager: FilterManager | undefined; diff --git a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx index c9390dcf1985a8..00f9d513a1a165 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/body/index.tsx @@ -7,6 +7,7 @@ import { EuiDataGrid, + EuiDataGridColumn, EuiDataGridCellValueElementProps, EuiDataGridControlColumn, EuiDataGridStyle, @@ -27,6 +28,7 @@ import React, { import { connect, ConnectedProps, useDispatch } from 'react-redux'; import { + TGridCellAction, TimelineId, TimelineTabs, BulkActionsProp, @@ -66,6 +68,7 @@ interface OwnProps { additionalControls?: React.ReactNode; browserFields: BrowserFields; data: TimelineItem[]; + defaultCellActions?: TGridCellAction[]; id: string; isEventViewer?: boolean; renderCellValue: (props: CellValueElementProps) => React.ReactNode; @@ -211,6 +214,7 @@ export const BodyComponent = React.memo( browserFields, columnHeaders, data, + defaultCellActions, excludedRowRendererIds, id, isEventViewer = false, @@ -461,6 +465,24 @@ export const BodyComponent = React.memo( sort, ]); + const columnsWithCellActions: EuiDataGridColumn[] = useMemo( + () => + columnHeaders.map((header) => { + const buildAction = (tGridCellAction: TGridCellAction) => + tGridCellAction({ + data: data.map((row) => row.data), + browserFields, + }); + + return { + ...header, + cellActions: + header.tGridCellActions?.map(buildAction) ?? defaultCellActions?.map(buildAction), + }; + }), + [browserFields, columnHeaders, data, defaultCellActions] + ); + const renderTGridCellValue: (x: EuiDataGridCellValueElementProps) => React.ReactNode = ({ columnId, rowIndex, @@ -494,7 +516,7 @@ export const BodyComponent = React.memo( ; docValueFields: DocValueFields[]; end: string; @@ -138,6 +139,7 @@ export interface TGridIntegratedProps { const TGridIntegratedComponent: React.FC = ({ browserFields, columns, + defaultCellActions, dataProviders, deletedEventIds, docValueFields, @@ -309,6 +311,7 @@ const TGridIntegratedComponent: React.FC = ({ activePage={pageInfo.activePage} browserFields={browserFields} data={nonDeletedEvents} + defaultCellActions={defaultCellActions} id={id} isEventViewer={true} loadPage={loadPage} diff --git a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx index 7caa6479a583c7..80b250e468170a 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/standalone/index.tsx @@ -13,7 +13,7 @@ import { useDispatch } from 'react-redux'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { Direction } from '../../../../common/search_strategy'; import type { CoreStart } from '../../../../../../../src/core/public'; -import { TimelineTabs } from '../../../../common/types/timeline'; +import { TGridCellAction, TimelineTabs } from '../../../../common/types/timeline'; import type { CellValueElementProps, ColumnHeaderOptions, @@ -98,6 +98,7 @@ const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>` export interface TGridStandaloneProps { columns: ColumnHeaderOptions[]; + defaultCellActions?: TGridCellAction[]; deletedEventIds: Readonly; end: string; loadingText: React.ReactNode; @@ -127,6 +128,7 @@ const basicUnit = (n: number) => i18n.UNIT(n); const TGridStandaloneComponent: React.FC = ({ columns, + defaultCellActions, deletedEventIds, end, loadingText, @@ -322,6 +324,7 @@ const TGridStandaloneComponent: React.FC = ({ activePage={pageInfo.activePage} browserFields={browserFields} data={nonDeletedEvents} + defaultCellActions={defaultCellActions} id={STANDALONE_ID} isEventViewer={true} loadPage={loadPage}