Skip to content

Commit

Permalink
[RAC] [TGrid] Implements cell actions in the TGrid (#107771)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
andrew-goldstein committed Aug 5, 2021
1 parent 764388e commit 5f409bc
Show file tree
Hide file tree
Showing 21 changed files with 556 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -192,6 +193,7 @@ export function AlertsTableTGrid(props: AlertsTableTGridProps) {
type: 'standalone',
columns,
deletedEventIds: [],
defaultCellActions: getDefaultCellActions({ enableFilterActions: false }),
end: rangeTo,
filters: [],
indexNames: [indexName],
Expand Down
Original file line number Diff line number Diff line change
@@ -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];
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -104,6 +105,7 @@ const AlertsTableComponent: React.FC<Props> = ({
<StatefulEventsViewer
pageFilters={alertsFilter}
defaultModel={alertsDefaultModel}
defaultCellActions={defaultCellActions}
end={endDate}
id={timelineId}
renderCellValue={DefaultCellRenderer}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -124,6 +125,7 @@ describe('EventsViewer', () => {
const mount = useMountAppended();

let testProps = {
defaultCellActions,
defaultModel: eventsDefaultModel,
end: to,
id: TimelineId.test,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -47,6 +48,7 @@ const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>`
`;

export interface OwnProps {
defaultCellActions?: TGridCellAction[];
defaultModel: SubsetTimelineModel;
end: string;
id: TimelineId;
Expand All @@ -73,6 +75,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
createTimeline,
columns,
dataProviders,
defaultCellActions,
deletedEventIds,
deleteEventQuery,
end,
Expand Down Expand Up @@ -140,6 +143,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
browserFields,
columns,
dataProviders: dataProviders!,
defaultCellActions,
deletedEventIds,
docValueFields,
end,
Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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
Expand All @@ -69,7 +95,7 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
value={value}
/>
</EuiPopover>
) : (
) : showTooltip ? (
<EuiToolTip
content={
<TooltipWithKeyboardShortcut
Expand All @@ -85,6 +111,8 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
>
{button}
</EuiToolTip>
) : (
button
);
}
);
Expand Down
Loading

0 comments on commit 5f409bc

Please sign in to comment.