Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution] Add CellActions (alpha version) component to ui_actions plugin #147434

Merged
merged 15 commits into from
Dec 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to update the Action type to make getIconType return IconType instead of string | undefined

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using IconTypeinstead of string makes sense to me. I think it is a string because EuiContextMenuItemIcon expects a string instead of IconType. So updating it might lead to cascading changes that could grow big.

Tagging @Dosant and @vadimkibana to collect their input.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to think about this. That would mean ui_actions plugin service layer would have a dependency on EUI, which maybe is fine, maybe not desirable.

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