Skip to content

Commit

Permalink
[Security Solution] Add CellActions (alpha version) component to ui_a…
Browse files Browse the repository at this point in the history
…ctions plugin (#147434)

## Summary

Create a `CellActions` component. It hooks into a UI-Actions trigger and
displays all available actions.
It has two modes, Hover_Actions and Always_Visible. 

You can run the storybook and take a look at the component: `yarn
storybook ui_actions` or access
https://ci-artifacts.kibana.dev/storybooks/pr-147434/226993c612bbe1719de6374219009bc69b0378d8/ui_actions/index.html

*** This component is still not in use.

<img width="117" alt="Screenshot 2022-12-13 at 13 13 46"
src="https://user-images.githubusercontent.com/1490444/207316029-26c7bad8-ae39-48ba-8059-cbacf01a98aa.png">


<img width="224" alt="Screenshot 2022-12-13 at 13 13 30"
src="https://user-images.githubusercontent.com/1490444/207316024-0d7706c8-bd59-42e8-bf6d-b5648fc818fd.png">


#### Why?
The security Solution team is creating a generic UI component to allow
teams to share actions between different plugins.
Initially, only the Security solution plugin will use this component and
deprecate the Security solution custom implementation. Some actions that
will be shared are: "copy to clipboard", "filter in", "filter out" and
"add to timeline".



#### How to use it:
This package provides a uniform interface for displaying UI actions for
a cell.
For the `CellActions` component to work, it must be wrapped by
`CellActionsContextProvider`. Ideally, the wrapper should stay on the
top of the rendering tree.

Example:
```JSX
<CellActionsContextProvider
    // call uiActions.getTriggerCompatibleActions(triggerId, data)
    getCompatibleActions={getCompatibleActions}>
    ...
    <CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
        Hover me
    </CellActions>
</CellActionsContextProvider>

```

`CellActions` component will display all compatible actions registered
for the trigger id.



### Checklist

- [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] [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
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
  • Loading branch information
machadoum authored Dec 20, 2022
1 parent 3cfada5 commit ed1f965
Show file tree
Hide file tree
Showing 25 changed files with 1,593 additions and 0 deletions.
1 change: 1 addition & 0 deletions .buildkite/scripts/steps/storybooks/build_and_upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const STORYBOOKS = [
'security_solution',
'shared_ux',
'triggers_actions_ui',
'ui_actions',
'ui_actions_enhanced',
'language_documentation_popover',
'unified_search',
Expand Down
1 change: 1 addition & 0 deletions src/dev/storybook/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,6 @@ export const storybookAliases = {
threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook',
triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook',
ui_actions_enhanced: 'src/plugins/ui_actions_enhanced/.storybook',
ui_actions: 'src/plugins/ui_actions/.storybook',
unified_search: 'src/plugins/unified_search/.storybook',
};
17 changes: 17 additions & 0 deletions src/plugins/ui_actions/.storybook/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*
* 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.
*/

const defaultConfig = require('@kbn/storybook').defaultConfig;

module.exports = {
...defaultConfig,
stories: ['../**/*.stories.tsx'],
reactOptions: {
strictMode: true,
},
};
17 changes: 17 additions & 0 deletions src/plugins/ui_actions/public/cell_actions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
This package provides a uniform interface for displaying UI actions for a cell.
For the `CellActions` component to work, it must be wrapped by `CellActionsContextProvider`. Ideally, the wrapper should stay on the top of the rendering tree.

Example:
```JSX
<CellActionsContextProvider
// call uiActions.getTriggerCompatibleActions(triggerId, data)
getCompatibleActions={getCompatibleActions}>
...
<CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
Hover me
</CellActions>
</CellActionsContextProvider>

```

`CellActions` component will display all compatible actions registered for the trigger id.
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 './cell_actions';
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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { Action } from '../../actions';
import { CellActionExecutionContext } from './cell_actions';

export const ActionItem = ({
action,
actionContext,
showTooltip,
}: {
action: Action;
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" />
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* 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 { CellActionsContextProvider } from './cell_actions_context';
import { makeAction } from '../mocks/helpers';
import { CellActions, CellActionsMode, CellActionsProps } from './cell_actions';

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) => (
<CellActionsContextProvider
// call uiActions getTriggerCompatibleActions(triggerId, data)
getTriggerCompatibleActions={getCompatibleActions}
>
<div style={{ paddingTop: '70px' }} />
{storyFn()}
</CellActionsContextProvider>
),
],
};

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

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

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

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

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

export const CellActionHoverPopup = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={TRIGGER_ID} field={FIELD}>
Hover me
</CellActions>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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, CellActionsMode } from './cell_actions';
import { CellActionsContextProvider } from './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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.ALWAYS_VISIBLE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsContextProvider>
);

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

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

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

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

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

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

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

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

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

expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
});
});
Loading

0 comments on commit ed1f965

Please sign in to comment.