Skip to content

Commit

Permalink
[Security Solution] [CellActions] Move to a package (#149057)
Browse files Browse the repository at this point in the history
Epic: #144943

## Summary

Moving the existing CellActions implementation to a new home. The
`kbn-cell-actions` package contains components and hooks that are going
to be used by solutions to show data cell actions with a consistent UI
across them.

Security Solution is going to start using it by migrating all
"hover-actions" to the unified implementation, but the usage is not
restricted to it. Any plugin can register and attach its own actions to
a trigger via uiActions, and use this package to render the CellActions
components in a consistent way.

The initial implementation was placed in the uiActions plugin itself due
to a types constraints
(https://github.com/elastic/kibana/tree/main/src/plugins/ui_actions/public/cell_actions),
the constraint has been solved so we are creating the package for it as
planned.

This PR only moves that implementation to the new package, with small
directory changes. The exported components are not being used anywhere
currently, so the implementation may change during the migration phase.

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
semd and kibanamachine authored Jan 19, 2023
1 parent fabfb43 commit bca73b7
Show file tree
Hide file tree
Showing 42 changed files with 1,998 additions and 0 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -896,6 +896,7 @@ packages/kbn-babel-register @elastic/kibana-operations
packages/kbn-babel-transform @elastic/kibana-operations
packages/kbn-bazel-runner @elastic/kibana-operations
packages/kbn-cases-components @elastic/response-ops
packages/kbn-cell-actions @elastic/security-threat-hunting-explore
packages/kbn-chart-icons @elastic/kibana-visualizations
packages/kbn-ci-stats-core @elastic/kibana-operations
packages/kbn-ci-stats-performance-metrics @elastic/kibana-operations
Expand Down
1 change: 1 addition & 0 deletions .i18nrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"autocomplete": "packages/kbn-securitysolution-autocomplete/src",
"bfetch": "src/plugins/bfetch",
"cases": ["packages/kbn-cases-components"],
"cellActions": "packages/kbn-cell-actions",
"charts": "src/plugins/charts",
"console": "src/plugins/console",
"contentManagement": "packages/content-management",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@
"@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader",
"@kbn/apm-utils": "link:packages/kbn-apm-utils",
"@kbn/cases-components": "link:packages/kbn-cases-components",
"@kbn/cell-actions": "link:packages/kbn-cell-actions",
"@kbn/chart-expressions-common": "link:src/plugins/chart_expressions/common",
"@kbn/chart-icons": "link:packages/kbn-chart-icons",
"@kbn/coloring": "link:packages/kbn-coloring",
Expand Down
9 changes: 9 additions & 0 deletions packages/kbn-cell-actions/.storybook/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

module.exports = require('@kbn/storybook').defaultConfig;
15 changes: 15 additions & 0 deletions packages/kbn-cell-actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
This package provides a uniform interface for displaying UI actions for a cell.
For the `CellActions` component to work, it must be wrapped by `CellActionsProvider`. Ideally, the wrapper should stay on the top of the rendering tree.

Example:

```JSX
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
[...]
<CellActions mode={CellActionsMode.HOVER} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
Hover me
</CellActions>
</CellActionsProvider>
```

`CellActions` component will display all compatible actions registered for the trigger id.
9 changes: 9 additions & 0 deletions packages/kbn-cell-actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

export * from './src';
13 changes: 13 additions & 0 deletions packages/kbn-cell-actions/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-cell-actions'],
};
5 changes: 5 additions & 0 deletions packages/kbn-cell-actions/kibana.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/cell-actions",
"owner": "@elastic/security-threat-hunting-explore"
}
7 changes: 7 additions & 0 deletions packages/kbn-cell-actions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@kbn/cell-actions",
"version": "1.0.0",
"description": "Uniform components for displaying UI actions in data cells",
"license": "SSPL-1.0 OR Elastic License 2.0",
"private": true
}
78 changes: 78 additions & 0 deletions packages/kbn-cell-actions/src/__stories__/cell_actions.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React from 'react';
import { ComponentStory } from '@storybook/react';
import { CellActionsProvider } from '../context/cell_actions_context';
import { makeAction } from '../mocks/helpers';
import { CellActions } from '../components/cell_actions';
import { CellActionsMode, type CellActionsProps } from '../types';

const TRIGGER_ID = 'testTriggerId';

const FIELD = { name: 'name', value: '123', type: 'text' };

const getCompatibleActions = () =>
Promise.resolve([
makeAction('Filter in', 'plusInCircle', 2),
makeAction('Filter out', 'minusInCircle', 3),
makeAction('Minimize', 'minimize', 1),
makeAction('Send email', 'email', 4),
makeAction('Pin field', 'pin', 5),
]);

export default {
title: 'CellAction',
decorators: [
(storyFn: Function) => (
<CellActionsProvider
// call uiActions getTriggerCompatibleActions(triggerId, data)
getTriggerCompatibleActions={getCompatibleActions}
>
<div style={{ paddingTop: '70px' }} />
{storyFn()}
</CellActionsProvider>
),
],
};

const CellActionsTemplate: ComponentStory<React.FC<CellActionsProps>> = (args) => (
<CellActions {...args}>Field value</CellActions>
);

export const DefaultWithControls = CellActionsTemplate.bind({});

DefaultWithControls.argTypes = {
mode: {
options: [CellActionsMode.HOVER, CellActionsMode.INLINE],
defaultValue: CellActionsMode.HOVER,
control: {
type: 'radio',
},
},
};

DefaultWithControls.args = {
showActionTooltips: true,
mode: CellActionsMode.INLINE,
triggerId: TRIGGER_ID,
field: FIELD,
visibleCellActions: 3,
};

export const CellActionInline = ({}: {}) => (
<CellActions mode={CellActionsMode.INLINE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
);

export const CellActionHoverPopup = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER} triggerId={TRIGGER_ID} field={FIELD}>
Hover me
</CellActions>
);
34 changes: 34 additions & 0 deletions packages/kbn-cell-actions/src/components/cell_action_item.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { render } from '@testing-library/react';
import React from 'react';
import { makeAction } from '../mocks/helpers';
import { CellActionExecutionContext } from '../types';
import { ActionItem } from './cell_action_item';

describe('ActionItem', () => {
it('renders', () => {
const action = makeAction('test-action');
const actionContext = {} as CellActionExecutionContext;
const { queryByTestId } = render(
<ActionItem action={action} actionContext={actionContext} showTooltip={false} />
);
expect(queryByTestId('actionItem-test-action')).toBeInTheDocument();
});

it('renders tooltip when showTooltip=true is received', () => {
const action = makeAction('test-action');
const actionContext = {} as CellActionExecutionContext;
const { container } = render(
<ActionItem action={action} actionContext={actionContext} showTooltip />
);

expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull();
});
});
44 changes: 44 additions & 0 deletions packages/kbn-cell-actions/src/components/cell_action_item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { useMemo } from 'react';

import { EuiButtonIcon, EuiToolTip, IconType } from '@elastic/eui';
import type { CellAction, CellActionExecutionContext } from '../types';

export const ActionItem = ({
action,
actionContext,
showTooltip,
}: {
action: CellAction;
actionContext: CellActionExecutionContext;
showTooltip: boolean;
}) => {
const actionProps = useMemo(
() => ({
iconType: action.getIconType(actionContext) as IconType,
onClick: () => action.execute(actionContext),
'data-test-subj': `actionItem-${action.id}`,
'aria-label': action.getDisplayName(actionContext),
}),
[action, actionContext]
);

if (!actionProps.iconType) return null;

return showTooltip ? (
<EuiToolTip
content={action.getDisplayNameTooltip ? action.getDisplayNameTooltip(actionContext) : ''}
>
<EuiButtonIcon {...actionProps} iconSize="s" />
</EuiToolTip>
) : (
<EuiButtonIcon {...actionProps} iconSize="s" />
);
};
75 changes: 75 additions & 0 deletions packages/kbn-cell-actions/src/components/cell_actions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { act, render } from '@testing-library/react';
import React from 'react';
import { CellActions } from './cell_actions';
import { CellActionsMode } from '../types';
import { CellActionsProvider } from '../context/cell_actions_context';

const TRIGGER_ID = 'test-trigger-id';
const FIELD = { name: 'name', value: '123', type: 'text' };

describe('CellActions', () => {
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;

const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.INLINE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsProvider>
);

await act(async () => {
await getActionsPromise;
});

expect(queryByTestId('cellActions')).toBeInTheDocument();
});

it('renders InlineActions when mode is INLINE', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;

const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.INLINE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsProvider>
);

await act(async () => {
await getActionsPromise;
});

expect(queryByTestId('inlineActions')).toBeInTheDocument();
});

it('renders HoverActionsPopover when mode is HOVER', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;

const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.HOVER} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsProvider>
);

await act(async () => {
await getActionsPromise;
});

expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
});
});
64 changes: 64 additions & 0 deletions packages/kbn-cell-actions/src/components/cell_actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import React, { useMemo, useRef } from 'react';
import { InlineActions } from './inline_actions';
import { HoverActionsPopover } from './hover_actions_popover';
import { CellActionsMode, type CellActionsProps, type CellActionExecutionContext } from '../types';

export const CellActions: React.FC<CellActionsProps> = ({
field,
triggerId,
children,
mode,
showActionTooltips = true,
visibleCellActions = 3,
metadata,
}) => {
const extraContentNodeRef = useRef<HTMLDivElement | null>(null);
const nodeRef = useRef<HTMLDivElement | null>(null);

const actionContext: CellActionExecutionContext = useMemo(
() => ({
field,
trigger: { id: triggerId },
extraContentNodeRef,
nodeRef,
metadata,
}),
[field, triggerId, metadata]
);

if (mode === CellActionsMode.HOVER) {
return (
<div ref={nodeRef} data-test-subj={'cellActions'}>
<HoverActionsPopover
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
>
{children}
</HoverActionsPopover>

<div ref={extraContentNodeRef} />
</div>
);
}

return (
<div ref={nodeRef} data-test-subj={'cellActions'}>
{children}
<InlineActions
actionContext={actionContext}
showActionTooltips={showActionTooltips}
visibleCellActions={visibleCellActions}
/>
<div ref={extraContentNodeRef} />
</div>
);
};
Loading

0 comments on commit bca73b7

Please sign in to comment.