[RAC] [TGrid] Implements cell actions in the TGrid (#107771)

## Summary

This PR implements cell actions in the `TGrid`, rendering them via `EuiDataGrid`, per the `Before` and `After` screenshots below:

### Before

Users previously hovered over a draggable field to view and trigger cell actions:

<img width="1348" alt="legacy_cell_actions" src="https://user-images.githubusercontent.com/4459398/128351498-49b4d224-6c51-4293-b14f-46bbb58f7cb3.png">

_Above: legacy `TGrid` cell action rendering_

### After

Cell actions are now rendered via `EuiDataGrid` cell actions:

<img width="997" alt="euidatagrid_cell_actions" src="https://user-images.githubusercontent.com/4459398/128358847-c5540ea4-8ba1-4b35-ab6b-3b3e39ae54ce.png">

_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 to 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 (
 *     <Component onClick={() => alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart">
 *       {'Love it'}
 *      </Component>
 *   );
 * };
 * ```
 */
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 to clipboard cell 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 `<field>` (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 (
    <SearchBar
      indexPatterns={dynamicIndexPattern}
      placeholder={i18n.translate('xpack.observability.alerts.searchBarPlaceholder', {
        defaultMessage: 'kibana.alert.evaluation.threshold > 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<ObservabilityPublicPluginsStart>();
```

## 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
This commit is contained in:
Andrew Goldstein 2021-08-05 12:46:07 -06:00 committed by GitHub
parent 764388e713
commit 5f409bc339
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 556 additions and 82 deletions

View file

@ -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],

View file

@ -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<ObservabilityPublicPluginsStart>();
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];

View file

@ -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,
}: {

View file

@ -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<Props> = ({
<StatefulEventsViewer
pageFilters={alertsFilter}
defaultModel={alertsDefaultModel}
defaultCellActions={defaultCellActions}
end={endDate}
id={timelineId}
renderCellValue={DefaultCellRenderer}

View file

@ -29,6 +29,7 @@ import { defaultRowRenderers } from '../../../timelines/components/timeline/body
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { useTimelineEvents } from '../../../timelines/containers';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';
jest.mock('../../lib/kibana');
@ -124,6 +125,7 @@ describe('EventsViewer', () => {
const mount = useMountAppended();
let testProps = {
defaultCellActions,
defaultModel: eventsDefaultModel,
end: to,
id: TimelineId.test,

View file

@ -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: [],

View file

@ -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<Props> = ({
createTimeline,
columns,
dataProviders,
defaultCellActions,
deletedEventIds,
deleteEventQuery,
end,
@ -140,6 +143,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
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 &&

View file

@ -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<Props> = 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<Props> = React.memo(
? SourcererScopeName.detections
: SourcererScopeName.default;
const { browserFields, indexPattern } = useSourcererScope(activeScope);
const button = useMemo(
() => (
<EuiButtonIcon
aria-label={SHOW_TOP(field)}
className="securitySolution__hoverActionButton"
data-test-subj="show-top-field"
iconSize="s"
iconType="visBarVertical"
onClick={onClick}
/>
),
[field, onClick]
() =>
Component ? (
<Component
aria-label={SHOW_TOP(field)}
data-test-subj="show-top-field"
iconType="visBarVertical"
onClick={onClick}
title={SHOW_TOP(field)}
>
{SHOW_TOP(field)}
</Component>
) : (
<EuiButtonIcon
aria-label={SHOW_TOP(field)}
className="securitySolution__hoverActionButton"
data-test-subj="show-top-field"
iconSize="s"
iconType="visBarVertical"
onClick={onClick}
/>
),
[Component, field, onClick]
);
return showTopN ? (
<EuiPopover button={button} isOpen={showTopN} closePopover={onClick}>
<StatefulTopN
@ -69,7 +95,7 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
value={value}
/>
</EuiPopover>
) : (
) : showTooltip ? (
<EuiToolTip
content={
<TooltipWithKeyboardShortcut
@ -85,6 +111,8 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
>
{button}
</EuiToolTip>
) : (
button
);
}
);

View file

@ -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,
}) && (
<ShowTopNButton
Component={Component}
data-test-subj="hover-actions-show-top-n"
field={columnId}
onClick={onClick}
onFilterAdded={onFilterAdded}
ownFocus={false}
showTopN={showTopN}
showTooltip={false}
timelineId={TimelineId.active}
value={value}
/>
)}
</>
);
},
({ 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,
})}
</>
);
},
];

View file

@ -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<AlertsTableComponentProps> = ({
return (
<StatefulEventsViewer
pageFilters={defaultFiltersMemo}
defaultCellActions={defaultCellActions}
defaultModel={defaultTimelineModel}
end={to}
currentFilter={filterGroup}

View file

@ -26,6 +26,7 @@ import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
import { defaultCellActions } from '../../../common/lib/cell_actions/default_cell_actions';
const EVENTS_HISTOGRAM_ID = 'eventsHistogramQuery';
@ -108,6 +109,7 @@ const EventsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> = ({
/>
)}
<StatefulEventsViewer
defaultCellActions={defaultCellActions}
defaultModel={eventsDefaultModel}
end={endDate}
id={TimelineId.hostsPageEvents}

View file

@ -5,8 +5,11 @@
* 2.0.
*/
import { EuiDataGridColumn } from '@elastic/eui';
import { ReactNode } from 'react';
import { EuiDataGridColumn, EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { IFieldSubType } from '../../../../../../../src/plugins/data/common';
import { BrowserFields } from '../../../search_strategy/index_fields';
import { TimelineNonEcsData } from '../../../search_strategy/timeline';
export type ColumnHeaderType = 'not-filtered' | 'text-filter';
@ -14,6 +17,40 @@ export type ColumnHeaderType = 'not-filtered' | 'text-filter';
/** Uniquely identifies a column */
export type ColumnId = string;
/**
* 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 (
* <Component onClick={() => alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart">
* {'Love it'}
* </Component>
* );
* };
* ```
*/
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;

View file

@ -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',
});

View file

@ -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<AddToTimelineButtonProps> = React.memo(
({
Component,
closePopOver,
dataProvider,
defaultFocusedButtonRef,
@ -96,6 +99,33 @@ const AddToTimelineButton: React.FC<AddToTimelineButtonProps> = React.memo(
}
}, [handleStartDragToTimeline, keyboardEvent, ownFocus]);
const button = useMemo(
() =>
Component ? (
<Component
aria-label={i18n.ADD_TO_TIMELINE}
buttonRef={defaultFocusedButtonRef}
data-test-subj="add-to-timeline"
iconType="timeline"
onClick={handleStartDragToTimeline}
title={i18n.ADD_TO_TIMELINE}
>
{i18n.ADD_TO_TIMELINE}
</Component>
) : (
<EuiButtonIcon
aria-label={i18n.ADD_TO_TIMELINE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="add-to-timeline"
iconSize="s"
iconType="timeline"
onClick={handleStartDragToTimeline}
/>
),
[Component, defaultFocusedButtonRef, handleStartDragToTimeline]
);
return showTooltip ? (
<EuiToolTip
content={
@ -110,26 +140,10 @@ const AddToTimelineButton: React.FC<AddToTimelineButtonProps> = React.memo(
/>
}
>
<EuiButtonIcon
aria-label={i18n.ADD_TO_TIMELINE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="add-to-timeline"
iconSize="s"
iconType="timeline"
onClick={handleStartDragToTimeline}
/>
{button}
</EuiToolTip>
) : (
<EuiButtonIcon
aria-label={i18n.ADD_TO_TIMELINE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="add-to-timeline"
iconSize="s"
iconType="timeline"
onClick={handleStartDragToTimeline}
/>
button
);
}
);

View file

@ -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<CopyProps> = React.memo(
({ closePopOver, field, isHoverAction, keyboardEvent, ownFocus, value }) => {
({ Component, closePopOver, field, isHoverAction, keyboardEvent, ownFocus, value }) => {
const { addSuccess } = useAppToasts();
const panelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ownFocus) {
@ -42,13 +51,34 @@ const CopyButton: React.FC<CopyProps> = 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 ? (
<Component
aria-label={COPY_TO_CLIPBOARD}
data-test-subj="copy-to-clipboard"
iconType="copyClipboard"
onClick={onClick}
title={COPY_TO_CLIPBOARD}
>
{COPY_TO_CLIPBOARD}
</Component>
) : (
<div ref={panelRef}>
<WithCopyToClipboard
data-test-subj="copy-to-clipboard"
isHoverAction={isHoverAction}
keyboardShortcut={ownFocus ? COPY_TO_CLIPBOARD_KEYBOARD_SHORTCUT : ''}
text={`${field}${value != null ? `: "${value}"` : ''}`}
text={text}
titleSummary={FIELD}
/>
</div>

View file

@ -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<FilterForValueProps> = React.memo(
({
Component,
closePopOver,
defaultFocusedButtonRef,
field,
@ -63,6 +64,33 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
}
}, [filterForValueFn, keyboardEvent, ownFocus]);
const button = useMemo(
() =>
Component ? (
<Component
aria-label={FILTER_FOR_VALUE}
buttonRef={defaultFocusedButtonRef}
data-test-subj="filter-for-value"
iconType="plusInCircle"
onClick={filterForValueFn}
title={FILTER_FOR_VALUE}
>
{FILTER_FOR_VALUE}
</Component>
) : (
<EuiButtonIcon
aria-label={FILTER_FOR_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-for-value"
iconSize="s"
iconType="plusInCircle"
onClick={filterForValueFn}
/>
),
[Component, defaultFocusedButtonRef, filterForValueFn]
);
return showTooltip ? (
<EuiToolTip
content={
@ -77,26 +105,10 @@ const FilterForValueButton: React.FC<FilterForValueProps> = React.memo(
/>
}
>
<EuiButtonIcon
aria-label={FILTER_FOR_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-for-value"
iconSize="s"
iconType="plusInCircle"
onClick={filterForValueFn}
/>
{button}
</EuiToolTip>
) : (
<EuiButtonIcon
aria-label={FILTER_FOR_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-for-value"
iconSize="s"
iconType="plusInCircle"
onClick={filterForValueFn}
/>
button
);
}
);

View file

@ -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<HoverActionComponentProps & FilterValueFnArgs> = React.memo(
({
Component,
closePopOver,
defaultFocusedButtonRef,
field,
@ -64,6 +65,33 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
}
}, [filterOutValueFn, keyboardEvent, ownFocus]);
const button = useMemo(
() =>
Component ? (
<Component
aria-label={FILTER_OUT_VALUE}
buttonRef={defaultFocusedButtonRef}
data-test-subj="filter-out-value"
iconType="minusInCircle"
onClick={filterOutValueFn}
title={FILTER_OUT_VALUE}
>
{FILTER_OUT_VALUE}
</Component>
) : (
<EuiButtonIcon
aria-label={FILTER_OUT_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-out-value"
iconSize="s"
iconType="minusInCircle"
onClick={filterOutValueFn}
/>
),
[Component, defaultFocusedButtonRef, filterOutValueFn]
);
return showTooltip ? (
<EuiToolTip
content={
@ -78,26 +106,10 @@ const FilterOutValueButton: React.FC<HoverActionComponentProps & FilterValueFnAr
/>
}
>
<EuiButtonIcon
aria-label={FILTER_OUT_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-out-value"
iconSize="s"
iconType="minusInCircle"
onClick={filterOutValueFn}
/>
{button}
</EuiToolTip>
) : (
<EuiButtonIcon
aria-label={FILTER_OUT_VALUE}
buttonRef={defaultFocusedButtonRef}
className="timelines__hoverActionButton"
data-test-subj="filter-out-value"
iconSize="s"
iconType="minusInCircle"
onClick={filterOutValueFn}
/>
button
);
}
);

View file

@ -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;

View file

@ -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<StatefulBodyProps>(
browserFields,
columnHeaders,
data,
defaultCellActions,
excludedRowRendererIds,
id,
isEventViewer = false,
@ -461,6 +465,24 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
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<StatefulBodyProps>(
<EuiDataGrid
data-test-subj="body-data-grid"
aria-label={i18n.TGRID_BODY_ARIA_LABEL}
columns={columnHeaders}
columns={columnsWithCellActions}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={gridStyle}
leadingControlColumns={leadingTGridControlColumns}

View file

@ -15,7 +15,7 @@ import { Direction } from '../../../../common/search_strategy';
import type { DocValueFields } from '../../../../common/search_strategy';
import type { CoreStart } from '../../../../../../../src/core/public';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import { TGridCellAction, TimelineId, TimelineTabs } from '../../../../common/types/timeline';
import type {
CellValueElementProps,
ColumnHeaderOptions,
@ -104,6 +104,7 @@ export interface TGridIntegratedProps {
browserFields: BrowserFields;
columns: ColumnHeaderOptions[];
dataProviders: DataProvider[];
defaultCellActions?: TGridCellAction[];
deletedEventIds: Readonly<string[]>;
docValueFields: DocValueFields[];
end: string;
@ -138,6 +139,7 @@ export interface TGridIntegratedProps {
const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
browserFields,
columns,
defaultCellActions,
dataProviders,
deletedEventIds,
docValueFields,
@ -309,6 +311,7 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
activePage={pageInfo.activePage}
browserFields={browserFields}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
id={id}
isEventViewer={true}
loadPage={loadPage}

View file

@ -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<string[]>;
end: string;
loadingText: React.ReactNode;
@ -127,6 +128,7 @@ const basicUnit = (n: number) => i18n.UNIT(n);
const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
columns,
defaultCellActions,
deletedEventIds,
end,
loadingText,
@ -322,6 +324,7 @@ const TGridStandaloneComponent: React.FC<TGridStandaloneProps> = ({
activePage={pageInfo.activePage}
browserFields={browserFields}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
id={STANDALONE_ID}
isEventViewer={true}
loadPage={loadPage}