[Security Solution] [CellActions] Move to a package (#149057)

Epic: https://github.com/elastic/kibana/issues/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>
This commit is contained in:
Sergi Massaneda 2023-01-19 11:52:10 +01:00 committed by GitHub
parent fabfb43740
commit bca73b72ed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1998 additions and 0 deletions

1
.github/CODEOWNERS vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/cell-actions",
"owner": "@elastic/security-threat-hunting-explore"
}

View file

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

View file

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

View file

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

View file

@ -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" />
);
};

View file

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

View file

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

View file

@ -0,0 +1,32 @@
/*
* 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 { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { ExtraActionsButton } from './extra_actions_button';
describe('ExtraActionsButton', () => {
it('renders', () => {
const { queryByTestId } = render(<ExtraActionsButton onClick={() => {}} showTooltip={false} />);
expect(queryByTestId('showExtraActionsButton')).toBeInTheDocument();
});
it('renders tooltip when showTooltip=true is received', () => {
const { container } = render(<ExtraActionsButton onClick={() => {}} showTooltip />);
expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull();
});
it('calls onClick when button is clicked', () => {
const onClick = jest.fn();
const { getByTestId } = render(<ExtraActionsButton onClick={onClick} showTooltip />);
fireEvent.click(getByTestId('showExtraActionsButton'));
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,35 @@
/*
* 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React from 'react';
import { SHOW_MORE_ACTIONS } from './translations';
interface ExtraActionsButtonProps {
onClick: () => void;
showTooltip: boolean;
}
export const ExtraActionsButton: React.FC<ExtraActionsButtonProps> = ({ onClick, showTooltip }) =>
showTooltip ? (
<EuiToolTip content={SHOW_MORE_ACTIONS}>
<EuiButtonIcon
data-test-subj="showExtraActionsButton"
aria-label={SHOW_MORE_ACTIONS}
iconType="boxesHorizontal"
onClick={onClick}
/>
</EuiToolTip>
) : (
<EuiButtonIcon
data-test-subj="showExtraActionsButton"
aria-label={SHOW_MORE_ACTIONS}
iconType="boxesHorizontal"
onClick={onClick}
/>
);

View file

@ -0,0 +1,90 @@
/*
* 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, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { ExtraActionsPopOver, ExtraActionsPopOverWithAnchor } from './extra_actions_popover';
const actionContext = makeActionContext();
describe('ExtraActionsPopOver', () => {
it('renders', () => {
const { queryByTestId } = render(
<ExtraActionsPopOver
actionContext={actionContext}
isOpen={false}
closePopOver={() => {}}
actions={[]}
button={<span />}
/>
);
expect(queryByTestId('extraActionsPopOver')).toBeInTheDocument();
});
it('executes action and close popover when menu item is clicked', async () => {
const executeAction = jest.fn();
const closePopOver = jest.fn();
const action = { ...makeAction('test-action'), execute: executeAction };
const { getByLabelText } = render(
<ExtraActionsPopOver
actionContext={actionContext}
isOpen={true}
closePopOver={closePopOver}
actions={[action]}
button={<span />}
/>
);
await act(async () => {
await fireEvent.click(getByLabelText('test-action'));
});
expect(executeAction).toHaveBeenCalled();
expect(closePopOver).toHaveBeenCalled();
});
});
describe('ExtraActionsPopOverWithAnchor', () => {
const anchorElement = document.createElement('span');
document.body.appendChild(anchorElement);
it('renders', () => {
const { queryByTestId } = render(
<ExtraActionsPopOverWithAnchor
actionContext={actionContext}
isOpen={false}
closePopOver={() => {}}
actions={[]}
anchorRef={{ current: anchorElement }}
/>
);
expect(queryByTestId('extraActionsPopOverWithAnchor')).toBeInTheDocument();
});
it('executes action and close popover when menu item is clicked', () => {
const executeAction = jest.fn();
const closePopOver = jest.fn();
const action = { ...makeAction('test-action'), execute: executeAction };
const { getByLabelText } = render(
<ExtraActionsPopOverWithAnchor
actionContext={actionContext}
isOpen={true}
closePopOver={closePopOver}
actions={[action]}
anchorRef={{ current: anchorElement }}
/>
);
fireEvent.click(getByLabelText('test-action'));
expect(executeAction).toHaveBeenCalled();
expect(closePopOver).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,132 @@
/*
* 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 {
EuiContextMenuItem,
EuiContextMenuPanel,
EuiPopover,
EuiScreenReaderOnly,
EuiWrappingPopover,
} from '@elastic/eui';
import React, { useMemo } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { EXTRA_ACTIONS_ARIA_LABEL, YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS } from './translations';
import type { CellAction, CellActionExecutionContext } from '../types';
const euiContextMenuItemCSS = css`
color: ${euiThemeVars.euiColorPrimaryText};
`;
interface ActionsPopOverProps {
actionContext: CellActionExecutionContext;
isOpen: boolean;
closePopOver: () => void;
actions: CellAction[];
button: JSX.Element;
}
export const ExtraActionsPopOver: React.FC<ActionsPopOverProps> = ({
actions,
actionContext,
isOpen,
closePopOver,
button,
}) => (
<EuiPopover
button={button}
isOpen={isOpen}
closePopover={closePopOver}
panelPaddingSize="xs"
anchorPosition={'downCenter'}
hasArrow
repositionOnScroll
ownFocus
data-test-subj="extraActionsPopOver"
aria-label={EXTRA_ACTIONS_ARIA_LABEL}
>
<ExtraActionsPopOverContent
actions={actions}
actionContext={actionContext}
closePopOver={closePopOver}
/>
</EuiPopover>
);
interface ExtraActionsPopOverWithAnchorProps
extends Pick<ActionsPopOverProps, 'actionContext' | 'closePopOver' | 'isOpen' | 'actions'> {
anchorRef: React.RefObject<HTMLElement>;
}
export const ExtraActionsPopOverWithAnchor = ({
anchorRef,
actionContext,
isOpen,
closePopOver,
actions,
}: ExtraActionsPopOverWithAnchorProps) => {
return anchorRef.current ? (
<EuiWrappingPopover
aria-label={EXTRA_ACTIONS_ARIA_LABEL}
button={anchorRef.current}
isOpen={isOpen}
closePopover={closePopOver}
panelPaddingSize="xs"
anchorPosition={'downCenter'}
hasArrow={false}
repositionOnScroll
ownFocus
attachToAnchor={false}
data-test-subj="extraActionsPopOverWithAnchor"
>
<ExtraActionsPopOverContent
actions={actions}
actionContext={actionContext}
closePopOver={closePopOver}
/>
</EuiWrappingPopover>
) : null;
};
type ExtraActionsPopOverContentProps = Pick<
ActionsPopOverProps,
'actionContext' | 'closePopOver' | 'actions'
>;
const ExtraActionsPopOverContent: React.FC<ExtraActionsPopOverContentProps> = ({
actionContext,
actions,
closePopOver,
}) => {
const items = useMemo(
() =>
actions.map((action) => (
<EuiContextMenuItem
css={euiContextMenuItemCSS}
key={action.id}
icon={action.getIconType(actionContext)}
aria-label={action.getDisplayName(actionContext)}
onClick={() => {
closePopOver();
action.execute(actionContext);
}}
>
{action.getDisplayName(actionContext)}
</EuiContextMenuItem>
)),
[actionContext, actions, closePopOver]
);
return (
<>
<EuiScreenReaderOnly>
<p>{YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(actionContext.field.name)}</p>
</EuiScreenReaderOnly>
<EuiContextMenuPanel size="s" items={items} />
</>
);
};

View file

@ -0,0 +1,191 @@
/*
* 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, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { HoverActionsPopover } from './hover_actions_popover';
import { CellActionsProvider } from '../context/cell_actions_context';
describe('HoverActionsPopover', () => {
const actionContext = makeActionContext();
const TestComponent = () => <span data-test-subj="test-component" />;
jest.useFakeTimers();
it('renders', () => {
const getActions = () => Promise.resolve([]);
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
children={null}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsProvider>
);
expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
});
it('renders actions when hovered', async () => {
const action = makeAction('test-action');
const getActionsPromise = Promise.resolve([action]);
const getActions = () => getActionsPromise;
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
expect(queryByLabelText('test-action')).toBeInTheDocument();
});
it('hide actions when mouse stops hovering', async () => {
const action = makeAction('test-action');
const getActionsPromise = Promise.resolve([action]);
const getActions = () => getActionsPromise;
const { queryByLabelText, getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
// Mouse leaves hover state
await act(async () => {
fireEvent.mouseLeave(getByTestId('test-component'));
});
expect(queryByLabelText('test-action')).not.toBeInTheDocument();
});
it('renders extra actions button', async () => {
const actions = [makeAction('test-action-1'), makeAction('test-action-2')];
const getActionsPromise = Promise.resolve(actions);
const getActions = () => getActionsPromise;
const { getByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
expect(getByTestId('showExtraActionsButton')).toBeInTheDocument();
});
it('shows extra actions when extra actions button is clicked', async () => {
const actions = [makeAction('test-action-1'), makeAction('test-action-2')];
const getActionsPromise = Promise.resolve(actions);
const getActions = () => getActionsPromise;
const { getByTestId, getByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
act(() => {
fireEvent.click(getByTestId('showExtraActionsButton'));
});
expect(getByLabelText('test-action-2')).toBeInTheDocument();
});
it('does not render visible actions if extra actions are already rendered', async () => {
const actions = [
makeAction('test-action-1'),
// extra actions
makeAction('test-action-2'),
makeAction('test-action-3'),
];
const getActionsPromise = Promise.resolve(actions);
const getActions = () => getActionsPromise;
const { getByTestId, queryByLabelText } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={2}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsProvider>
);
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
act(() => {
fireEvent.click(getByTestId('showExtraActionsButton'));
});
await hoverElement(getByTestId('test-component'), async () => {
await getActionsPromise;
jest.runAllTimers();
});
expect(queryByLabelText('test-action-1')).not.toBeInTheDocument();
expect(queryByLabelText('test-action-2')).toBeInTheDocument();
expect(queryByLabelText('test-action-3')).toBeInTheDocument();
});
});
const hoverElement = async (element: Element, waitForChange: () => Promise<unknown>) => {
await act(async () => {
fireEvent.mouseEnter(element);
await waitForChange();
});
};

View file

@ -0,0 +1,168 @@
/*
* 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 { EuiPopover, EuiScreenReaderOnly } from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { euiThemeVars } from '@kbn/ui-theme';
import { css } from '@emotion/react';
import { debounce } from 'lodash';
import { ActionItem } from './cell_action_item';
import { ExtraActionsButton } from './extra_actions_button';
import { ACTIONS_AREA_LABEL, YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS } from './translations';
import { partitionActions } from '../hooks/actions';
import { ExtraActionsPopOverWithAnchor } from './extra_actions_popover';
import { CellActionExecutionContext } from '../types';
import { useLoadActionsFn } from '../hooks/use_load_actions';
/** This class is added to the document body while dragging */
export const IS_DRAGGING_CLASS_NAME = 'is-dragging';
// Overwrite Popover default minWidth to avoid displaying empty space
const PANEL_STYLE = { minWidth: `24px` };
const hoverContentWrapperCSS = css`
padding: 0 ${euiThemeVars.euiSizeS};
`;
/**
* To avoid expensive changes to the DOM, delay showing the popover menu
*/
const HOVER_INTENT_DELAY = 100; // ms
interface Props {
children: React.ReactNode;
visibleCellActions: number;
actionContext: CellActionExecutionContext;
showActionTooltips: boolean;
}
export const HoverActionsPopover = React.memo<Props>(
({ children, visibleCellActions, actionContext, showActionTooltips }) => {
const contentRef = useRef<HTMLDivElement>(null);
const [isExtraActionsPopoverOpen, setIsExtraActionsPopoverOpen] = useState(false);
const [showHoverContent, setShowHoverContent] = useState(false);
const popoverRef = useRef<EuiPopover>(null);
const [{ value: actions }, loadActions] = useLoadActionsFn();
const { visibleActions, extraActions } = useMemo(
() => partitionActions(actions ?? [], visibleCellActions),
[actions, visibleCellActions]
);
const closePopover = useCallback(() => {
setShowHoverContent(false);
}, []);
const closeExtraActions = useCallback(
() => setIsExtraActionsPopoverOpen(false),
[setIsExtraActionsPopoverOpen]
);
const onShowExtraActionsClick = useCallback(() => {
setIsExtraActionsPopoverOpen(true);
closePopover();
}, [closePopover, setIsExtraActionsPopoverOpen]);
const openPopOverDebounced = useMemo(
() =>
debounce(() => {
if (!document.body.classList.contains(IS_DRAGGING_CLASS_NAME)) {
setShowHoverContent(true);
}
}, HOVER_INTENT_DELAY),
[]
);
// prevent setState on an unMounted component
useEffect(() => {
return () => {
openPopOverDebounced.cancel();
};
}, [openPopOverDebounced]);
const onMouseEnter = useCallback(async () => {
// Do not open actions with extra action popover is open
if (isExtraActionsPopoverOpen) return;
// memoize actions after the first call
if (actions === undefined) {
loadActions(actionContext);
}
openPopOverDebounced();
}, [isExtraActionsPopoverOpen, actions, openPopOverDebounced, loadActions, actionContext]);
const onMouseLeave = useCallback(() => {
closePopover();
}, [closePopover]);
const content = useMemo(() => {
return (
// Hack - Forces extra actions popover to close when hover content is clicked.
// This hack is required because we anchor the popover to the hover content instead
// of anchoring it to the button that triggers the popover.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div ref={contentRef} onMouseEnter={onMouseEnter} onClick={closeExtraActions}>
{children}
</div>
);
}, [onMouseEnter, closeExtraActions, children]);
return (
<>
<div onMouseLeave={onMouseLeave}>
<EuiPopover
panelStyle={PANEL_STYLE}
ref={popoverRef}
anchorPosition={'downCenter'}
button={content}
closePopover={closePopover}
hasArrow={false}
isOpen={showHoverContent}
panelPaddingSize="none"
repositionOnScroll
ownFocus={false}
data-test-subj={'hoverActionsPopover'}
aria-label={ACTIONS_AREA_LABEL}
>
{showHoverContent ? (
<div css={hoverContentWrapperCSS}>
<EuiScreenReaderOnly>
<p>{YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(actionContext.field.name)}</p>
</EuiScreenReaderOnly>
{visibleActions.map((action) => (
<ActionItem
key={action.id}
action={action}
actionContext={actionContext}
showTooltip={showActionTooltips}
/>
))}
{extraActions.length > 0 ? (
<ExtraActionsButton
onClick={onShowExtraActionsClick}
showTooltip={showActionTooltips}
/>
) : null}
</div>
) : null}
</EuiPopover>
</div>
<ExtraActionsPopOverWithAnchor
actions={extraActions}
anchorRef={contentRef}
actionContext={actionContext}
closePopOver={closeExtraActions}
isOpen={isExtraActionsPopoverOpen}
/>
</>
);
}
);

View file

@ -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 { CellActions } from './cell_actions';

View file

@ -0,0 +1,63 @@
/*
* 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 { makeAction, makeActionContext } from '../mocks/helpers';
import { InlineActions } from './inline_actions';
import { CellActionsProvider } from '../context/cell_actions_context';
describe('InlineActions', () => {
const actionContext = makeActionContext();
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(queryByTestId('inlineActions')).toBeInTheDocument();
});
it('renders all actions', async () => {
const getActionsPromise = Promise.resolve([
makeAction('action-1'),
makeAction('action-2'),
makeAction('action-3'),
makeAction('action-4'),
makeAction('action-5'),
]);
const getActions = () => getActionsPromise;
const { queryAllByRole } = render(
<CellActionsProvider getTriggerCompatibleActions={getActions}>
<InlineActions
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(queryAllByRole('button').length).toBe(5);
});
});

View file

@ -0,0 +1,62 @@
/*
* 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, { useCallback, useMemo, useState } from 'react';
import { ActionItem } from './cell_action_item';
import { usePartitionActions } from '../hooks/actions';
import { ExtraActionsPopOver } from './extra_actions_popover';
import { ExtraActionsButton } from './extra_actions_button';
import type { CellActionExecutionContext } from '../types';
import { useLoadActions } from '../hooks/use_load_actions';
interface InlineActionsProps {
actionContext: CellActionExecutionContext;
showActionTooltips: boolean;
visibleCellActions: number;
}
export const InlineActions: React.FC<InlineActionsProps> = ({
actionContext,
showActionTooltips,
visibleCellActions,
}) => {
const { value: allActions } = useLoadActions(actionContext);
const { extraActions, visibleActions } = usePartitionActions(
allActions ?? [],
visibleCellActions
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopOver = useCallback(() => setIsPopoverOpen((isOpen) => !isOpen), []);
const closePopOver = useCallback(() => setIsPopoverOpen(false), []);
const button = useMemo(
() => <ExtraActionsButton onClick={togglePopOver} showTooltip={showActionTooltips} />,
[togglePopOver, showActionTooltips]
);
return (
<span data-test-subj="inlineActions">
{visibleActions.map((action, index) => (
<ActionItem
key={`action-item-${index}`}
action={action}
actionContext={actionContext}
showTooltip={showActionTooltips}
/>
))}
{extraActions.length > 0 ? (
<ExtraActionsPopOver
actions={extraActions}
actionContext={actionContext}
button={button}
closePopOver={closePopOver}
isOpen={isPopoverOpen}
/>
) : null}
</span>
);
};

View file

@ -0,0 +1,26 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) =>
i18n.translate('cellActions.youAreInADialogContainingOptionsScreenReaderOnly', {
values: { fieldName },
defaultMessage: `You are in a dialog, containing options for field {fieldName}. Press tab to navigate options. Press escape to exit.`,
});
export const EXTRA_ACTIONS_ARIA_LABEL = i18n.translate('cellActions.extraActionsAriaLabel', {
defaultMessage: 'Extra actions',
});
export const SHOW_MORE_ACTIONS = i18n.translate('cellActions.showMoreActionsLabel', {
defaultMessage: 'More actions',
});
export const ACTIONS_AREA_LABEL = i18n.translate('cellActions.actionsAriaLabel', {
defaultMessage: 'Actions',
});

View file

@ -0,0 +1,84 @@
/*
* 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 { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { CellActionsProvider, useCellActionsContext } from './cell_actions_context';
const action = makeAction('action-1', 'icon', 1);
const mockGetTriggerCompatibleActions = jest.fn(async () => [action]);
const ContextWrapper: React.FC = ({ children }) => (
<CellActionsProvider getTriggerCompatibleActions={mockGetTriggerCompatibleActions}>
{children}
</CellActionsProvider>
);
describe('CellActionContext', () => {
const triggerId = 'triggerId';
const actionContext = makeActionContext();
beforeEach(() => {
jest.clearAllMocks();
});
it('should throw error when context not found', () => {
const { result } = renderHook(useCellActionsContext);
expect(result.error).toEqual(
new Error('No CellActionsContext found. Please wrap the application with CellActionsProvider')
);
});
it('should call getTriggerCompatibleActions and return actions', async () => {
const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper });
const actions = await result.current.getActions(actionContext);
expect(mockGetTriggerCompatibleActions).toHaveBeenCalledWith(triggerId, actionContext);
expect(actions).toEqual([action]);
});
it('should sort actions by order', async () => {
const firstAction = makeAction('action-1', 'icon', 1);
const secondAction = makeAction('action-2', 'icon', 2);
mockGetTriggerCompatibleActions.mockResolvedValueOnce([secondAction, firstAction]);
const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper });
const actions = await result.current.getActions(actionContext);
expect(actions).toEqual([firstAction, secondAction]);
});
it('should sort actions by id when order is undefined', async () => {
const firstAction = makeAction('action-1');
const secondAction = makeAction('action-2');
mockGetTriggerCompatibleActions.mockResolvedValueOnce([secondAction, firstAction]);
const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper });
const actions = await result.current.getActions(actionContext);
expect(actions).toEqual([firstAction, secondAction]);
});
it('should sort actions by id and order', async () => {
const actionWithoutOrder = makeAction('action-1-no-order');
const secondAction = makeAction('action-2', 'icon', 2);
const thirdAction = makeAction('action-3', 'icon', 3);
mockGetTriggerCompatibleActions.mockResolvedValueOnce([
thirdAction,
secondAction,
actionWithoutOrder,
]);
const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper });
const actions = await result.current.getActions(actionContext);
expect(actions).toEqual([secondAction, thirdAction, actionWithoutOrder]);
});
});

View file

@ -0,0 +1,40 @@
/*
* 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 { orderBy } from 'lodash/fp';
import React, { createContext, FC, useCallback, useContext } from 'react';
import type { CellAction, CellActionsProviderProps, GetActions } from '../types';
const CellActionsContext = createContext<{ getActions: GetActions } | null>(null);
export const CellActionsProvider: FC<CellActionsProviderProps> = ({
children,
getTriggerCompatibleActions,
}) => {
const getActions = useCallback<GetActions>(
(context) =>
getTriggerCompatibleActions(context.trigger.id, context).then((actions) =>
orderBy(['order', 'id'], ['asc', 'asc'], actions)
) as Promise<CellAction[]>,
[getTriggerCompatibleActions]
);
return (
<CellActionsContext.Provider value={{ getActions }}>{children}</CellActionsContext.Provider>
);
};
export const useCellActionsContext = () => {
const context = useContext(CellActionsContext);
if (!context) {
throw new Error(
'No CellActionsContext found. Please wrap the application with CellActionsProvider'
);
}
return context;
};

View file

@ -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 { CellActionsProvider } from './cell_actions_context';

View file

@ -0,0 +1,85 @@
/*
* 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 { makeAction } from '../mocks/helpers';
import { partitionActions } from './actions';
describe('InlineActions', () => {
it('returns an empty array when actions is an empty array', async () => {
const { extraActions, visibleActions } = partitionActions([], 5);
expect(visibleActions).toEqual([]);
expect(extraActions).toEqual([]);
});
it('returns only visible actions when visibleCellActions > actions.length', async () => {
const actions = [makeAction('action-1'), makeAction('action-2'), makeAction('action-3')];
const { extraActions, visibleActions } = partitionActions(actions, 4);
expect(visibleActions.length).toEqual(actions.length);
expect(extraActions).toEqual([]);
});
it('returns only extra actions when visibleCellActions is 1', async () => {
const actions = [makeAction('action-1'), makeAction('action-2'), makeAction('action-3')];
const { extraActions, visibleActions } = partitionActions(actions, 1);
expect(visibleActions).toEqual([]);
expect(extraActions.length).toEqual(actions.length);
});
it('returns only extra actions when visibleCellActions is 0', async () => {
const actions = [makeAction('action-1'), makeAction('action-2'), makeAction('action-3')];
const { extraActions, visibleActions } = partitionActions(actions, 0);
expect(visibleActions).toEqual([]);
expect(extraActions.length).toEqual(actions.length);
});
it('returns only extra actions when visibleCellActions is negative', async () => {
const actions = [makeAction('action-1'), makeAction('action-2'), makeAction('action-3')];
const { extraActions, visibleActions } = partitionActions(actions, -6);
expect(visibleActions).toEqual([]);
expect(extraActions.length).toEqual(actions.length);
});
it('returns only one visible action when visibleCellActionss 2 and action.length is 3', async () => {
const { extraActions, visibleActions } = partitionActions(
[makeAction('action-1'), makeAction('action-2'), makeAction('action-3')],
2
);
expect(visibleActions.length).toEqual(1);
expect(extraActions.length).toEqual(2);
});
it('returns two visible actions when visibleCellActions is 3 and action.length is 5', async () => {
const { extraActions, visibleActions } = partitionActions(
[
makeAction('action-1'),
makeAction('action-2'),
makeAction('action-3'),
makeAction('action-4'),
makeAction('action-5'),
],
3
);
expect(visibleActions.length).toEqual(2);
expect(extraActions.length).toEqual(3);
});
it('returns three visible actions when visibleCellActions is 3 and action.length is 3', async () => {
const { extraActions, visibleActions } = partitionActions(
[makeAction('action-1'), makeAction('action-2'), makeAction('action-3')],
3
);
expect(visibleActions.length).toEqual(3);
expect(extraActions.length).toEqual(0);
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { useMemo } from 'react';
import type { PartitionedActions, CellAction } from '../types';
export const partitionActions = (actions: CellAction[], visibleCellActions: number) => {
if (visibleCellActions <= 1) return { extraActions: actions, visibleActions: [] };
if (actions.length <= visibleCellActions) return { extraActions: [], visibleActions: actions };
return {
visibleActions: actions.slice(0, visibleCellActions - 1),
extraActions: actions.slice(visibleCellActions - 1, actions.length),
};
};
export const usePartitionActions = (
allActions: CellAction[],
visibleCellActions: number
): PartitionedActions => {
return useMemo(() => {
return partitionActions(allActions ?? [], visibleCellActions);
}, [allActions, visibleCellActions]);
};

View file

@ -0,0 +1,12 @@
/*
* 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 {
useDataGridColumnsCellActions,
type UseDataGridColumnsCellActionsProps,
} from './use_data_grid_column_cell_actions';

View file

@ -0,0 +1,166 @@
/*
* 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, { JSXElementConstructor } from 'react';
import {
EuiButtonEmpty,
EuiDataGridColumnCellActionProps,
type EuiDataGridColumnCellAction,
} from '@elastic/eui';
import { render } from '@testing-library/react';
import { act, renderHook } from '@testing-library/react-hooks';
import { makeAction } from '../mocks/helpers';
import {
useDataGridColumnsCellActions,
UseDataGridColumnsCellActionsProps,
} from './use_data_grid_column_cell_actions';
const action1 = makeAction('action-1', 'icon1', 1);
action1.execute = jest.fn();
const action2 = makeAction('action-2', 'icon2', 2);
action2.execute = jest.fn();
const actions = [action1, action2];
const mockGetActions = jest.fn(async () => actions);
jest.mock('../context/cell_actions_context', () => ({
useCellActionsContext: () => ({ getActions: mockGetActions }),
}));
const field1 = { name: 'column1', values: ['0.0', '0.1', '0.2', '0.3'], type: 'text' };
const field2 = { name: 'column2', values: ['1.0', '1.1', '1.2', '1.3'], type: 'keyword' };
const columns = [{ id: field1.name }, { id: field2.name }];
const useDataGridColumnsCellActionsProps: UseDataGridColumnsCellActionsProps = {
fields: [field1, field2],
triggerId: 'testTriggerId',
metadata: { some: 'value' },
};
const renderCellAction = (
columnCellAction: EuiDataGridColumnCellAction,
props: Partial<EuiDataGridColumnCellActionProps> = {}
) => {
const CellAction = columnCellAction as JSXElementConstructor<EuiDataGridColumnCellActionProps>;
return render(
<CellAction
Component={EuiButtonEmpty}
colIndex={0}
rowIndex={0}
columnId={''}
isExpanded={false}
{...props}
/>
);
};
describe('useDataGridColumnsCellActions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return array with actions for each columns', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
expect(result.current).toHaveLength(columns.length);
expect(result.current[0]).toHaveLength(1); // loader
await waitForNextUpdate();
expect(result.current).toHaveLength(columns.length);
expect(result.current[0]).toHaveLength(actions.length);
});
it('should render cell actions loading state', async () => {
const { result } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await act(async () => {
const cellAction = renderCellAction(result.current[0][0]);
expect(cellAction.getByTestId('dataGridColumnCellAction-loading')).toBeInTheDocument();
});
});
it('should render the cell actions', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await waitForNextUpdate();
const cellAction1 = renderCellAction(result.current[0][0]);
expect(cellAction1.getByTestId(`dataGridColumnCellAction-${action1.id}`)).toBeInTheDocument();
expect(cellAction1.getByText(action1.getDisplayName())).toBeInTheDocument();
const cellAction2 = renderCellAction(result.current[0][1]);
expect(cellAction2.getByTestId(`dataGridColumnCellAction-${action2.id}`)).toBeInTheDocument();
expect(cellAction2.getByText(action2.getDisplayName())).toBeInTheDocument();
});
it('should execute the action on click', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await waitForNextUpdate();
const cellAction = renderCellAction(result.current[0][0]);
cellAction.getByTestId(`dataGridColumnCellAction-${action1.id}`).click();
expect(action1.execute).toHaveBeenCalled();
});
it('should execute the action with correct context', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await waitForNextUpdate();
const cellAction1 = renderCellAction(result.current[0][0], { rowIndex: 1 });
cellAction1.getByTestId(`dataGridColumnCellAction-${action1.id}`).click();
expect(action1.execute).toHaveBeenCalledWith(
expect.objectContaining({
field: { name: field1.name, type: field1.type, value: field1.values[1] },
trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
})
);
const cellAction2 = renderCellAction(result.current[1][1], { rowIndex: 2 });
cellAction2.getByTestId(`dataGridColumnCellAction-${action2.id}`).click();
expect(action2.execute).toHaveBeenCalledWith(
expect.objectContaining({
field: { name: field2.name, type: field2.type, value: field2.values[2] },
trigger: { id: useDataGridColumnsCellActionsProps.triggerId },
})
);
});
it('should execute the action with correct page value', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await waitForNextUpdate();
const cellAction = renderCellAction(result.current[0][0], { rowIndex: 25 });
cellAction.getByTestId(`dataGridColumnCellAction-${action1.id}`).click();
expect(action1.execute).toHaveBeenCalledWith(
expect.objectContaining({
field: { name: field1.name, type: field1.type, value: field1.values[1] },
})
);
});
});

View file

@ -0,0 +1,107 @@
/*
* 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 { EuiLoadingSpinner, type EuiDataGridColumnCellAction } from '@elastic/eui';
import type {
CellAction,
CellActionExecutionContext,
CellActionField,
CellActionsProps,
} from '../types';
import { useBulkLoadActions } from './use_load_actions';
interface BulkField extends Pick<CellActionField, 'name' | 'type'> {
/**
* Array containing all the values of the field in the visible page, indexed by rowIndex
*/
values: Array<string | string[] | null | undefined>;
}
export interface UseDataGridColumnsCellActionsProps
extends Pick<CellActionsProps, 'triggerId' | 'metadata'> {
fields: BulkField[];
}
export const useDataGridColumnsCellActions = ({
fields,
triggerId,
metadata,
}: UseDataGridColumnsCellActionsProps): EuiDataGridColumnCellAction[][] => {
const bulkContexts: CellActionExecutionContext[] = useMemo(
() =>
fields.map(({ values, ...field }) => ({
field, // we are getting the actions for the whole column field, so the compatibility check will be done without the value
trigger: { id: triggerId },
metadata,
})),
[fields, triggerId, metadata]
);
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts);
const columnsCellActions = useMemo<EuiDataGridColumnCellAction[][]>(() => {
if (loading) {
return fields.map(() => [
() => <EuiLoadingSpinner size="s" data-test-subj="dataGridColumnCellAction-loading" />,
]);
}
if (!columnsActions) {
return [];
}
return columnsActions.map((actions, columnIndex) =>
actions.map((action) =>
createColumnCellAction({ action, metadata, triggerId, field: fields[columnIndex] })
)
);
}, [columnsActions, fields, loading, metadata, triggerId]);
return columnsCellActions;
};
interface CreateColumnCellActionParams extends Pick<CellActionsProps, 'triggerId' | 'metadata'> {
field: BulkField;
action: CellAction;
}
const createColumnCellAction = ({
field,
action,
metadata,
triggerId,
}: CreateColumnCellActionParams): EuiDataGridColumnCellAction =>
function ColumnCellAction({ Component, rowIndex }) {
const nodeRef = useRef<HTMLElement | null>(null);
const extraContentNodeRef = useRef<HTMLDivElement | null>(null);
const { name, type, values } = field;
// rowIndex refers to all pages, we need to use the row index relative to the page to get the value
const value = values[rowIndex % values.length];
const actionContext: CellActionExecutionContext = {
field: { name, type, value },
trigger: { id: triggerId },
extraContentNodeRef,
nodeRef,
metadata,
};
return (
<Component
buttonRef={() => nodeRef}
aria-label={action.getDisplayName(actionContext)}
title={action.getDisplayName(actionContext)}
data-test-subj={`dataGridColumnCellAction-${action.id}`}
iconType={action.getIconType(actionContext)!}
onClick={() => {
action.execute(actionContext);
}}
>
{action.getDisplayName(actionContext)}
<div ref={() => extraContentNodeRef} />
</Component>
);
};

View file

@ -0,0 +1,85 @@
/*
* 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, renderHook } from '@testing-library/react-hooks';
import { makeAction, makeActionContext } from '../mocks/helpers';
import { useBulkLoadActions, useLoadActions, useLoadActionsFn } from './use_load_actions';
const action = makeAction('action-1', 'icon', 1);
const mockGetActions = jest.fn(async () => [action]);
jest.mock('../context/cell_actions_context', () => ({
useCellActionsContext: () => ({ getActions: mockGetActions }),
}));
describe('useLoadActions', () => {
const actionContext = makeActionContext();
beforeEach(() => {
jest.clearAllMocks();
});
it('loads actions when useLoadActions called', async () => {
const { result, waitForNextUpdate } = renderHook(useLoadActions, {
initialProps: actionContext,
});
expect(result.current.value).toBeUndefined();
expect(result.current.loading).toEqual(true);
expect(mockGetActions).toHaveBeenCalledTimes(1);
expect(mockGetActions).toHaveBeenCalledWith(actionContext);
await waitForNextUpdate();
expect(result.current.value).toEqual([action]);
expect(result.current.loading).toEqual(false);
});
it('loads actions when useLoadActionsFn function is called', async () => {
const { result, waitForNextUpdate } = renderHook(useLoadActionsFn);
const [{ value: valueBeforeCall, loading: loadingBeforeCall }, loadActions] = result.current;
expect(valueBeforeCall).toBeUndefined();
expect(loadingBeforeCall).toEqual(false);
expect(mockGetActions).not.toHaveBeenCalled();
act(() => {
loadActions(actionContext);
});
const [{ value: valueAfterCall, loading: loadingAfterCall }] = result.current;
expect(valueAfterCall).toBeUndefined();
expect(loadingAfterCall).toEqual(true);
expect(mockGetActions).toHaveBeenCalledTimes(1);
expect(mockGetActions).toHaveBeenCalledWith(actionContext);
await waitForNextUpdate();
const [{ value: valueAfterUpdate, loading: loadingAfterUpdate }] = result.current;
expect(valueAfterUpdate).toEqual([action]);
expect(loadingAfterUpdate).toEqual(false);
});
it('loads bulk actions array when useBulkLoadActions is called', async () => {
const actionContext2 = makeActionContext({ trigger: { id: 'triggerId2' } });
const actionContexts = [actionContext, actionContext2];
const { result, waitForNextUpdate } = renderHook(useBulkLoadActions, {
initialProps: actionContexts,
});
expect(result.current.value).toBeUndefined();
expect(result.current.loading).toEqual(true);
expect(mockGetActions).toHaveBeenCalledTimes(2);
expect(mockGetActions).toHaveBeenCalledWith(actionContext);
expect(mockGetActions).toHaveBeenCalledWith(actionContext2);
await waitForNextUpdate();
expect(result.current.value).toEqual([[action], [action]]);
expect(result.current.loading).toEqual(false);
});
});

View file

@ -0,0 +1,36 @@
/*
* 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 useAsync from 'react-use/lib/useAsync';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { useCellActionsContext } from '../context/cell_actions_context';
import { CellActionExecutionContext } from '../types';
/**
* Performs the getActions async call and returns its value
*/
export const useLoadActions = (context: CellActionExecutionContext) => {
const { getActions } = useCellActionsContext();
return useAsync(() => getActions(context), []);
};
/**
* Returns a function to perform the getActions async call
*/
export const useLoadActionsFn = () => {
const { getActions } = useCellActionsContext();
return useAsyncFn(getActions, []);
};
/**
* Groups getActions calls for an array of contexts in one async bulk operation
*/
export const useBulkLoadActions = (contexts: CellActionExecutionContext[]) => {
const { getActions } = useCellActionsContext();
return useAsync(() => Promise.all(contexts.map((context) => getActions(context))), []);
};

View file

@ -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.
*/
export { CellActions } from './components';
export { CellActionsProvider } from './context';
export { useDataGridColumnsCellActions, type UseDataGridColumnsCellActionsProps } from './hooks';
export { CellActionsMode } from './types';
export type { CellAction, CellActionExecutionContext } from './types';

View file

@ -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 { CellActionExecutionContext } from '../types';
export const makeAction = (actionsName: string, icon: string = 'icon', order?: number) => ({
id: actionsName,
type: actionsName,
order,
getIconType: () => icon,
getDisplayName: () => actionsName,
getDisplayNameTooltip: () => actionsName,
isCompatible: () => Promise.resolve(true),
execute: () => {
alert(actionsName);
return Promise.resolve();
},
});
export const makeActionContext = (
override: Partial<CellActionExecutionContext> = {}
): CellActionExecutionContext => ({
trigger: { id: 'triggerId' },
field: {
name: 'fieldName',
type: 'keyword',
},
...override,
});

View file

@ -0,0 +1,105 @@
/*
* 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 type {
Action,
ActionExecutionContext,
UiActionsService,
} from '@kbn/ui-actions-plugin/public';
export type CellAction = Action<CellActionExecutionContext>;
export interface CellActionsProviderProps {
/**
* Please assign `uiActions.getTriggerCompatibleActions` function.
* This function should return a list of actions for a triggerId that are compatible with the provided context.
*/
getTriggerCompatibleActions: UiActionsService['getTriggerCompatibleActions'];
}
export type GetActions = (context: CellActionExecutionContext) => Promise<CellAction[]>;
export interface CellActionField {
/**
* Field name.
* Example: 'host.name'
*/
name: string;
/**
* Field type.
* Example: 'keyword'
*/
type: string;
/**
* Field value.
* Example: 'My-Laptop'
*/
value?: string | string[] | null;
}
export interface PartitionedActions {
extraActions: CellAction[];
visibleActions: CellAction[];
}
export interface CellActionExecutionContext extends ActionExecutionContext {
field: CellActionField;
/**
* Ref to a DOM node where the action can add custom HTML.
*/
extraContentNodeRef?: React.MutableRefObject<HTMLDivElement | null>;
/**
* Ref to the node where the cell action are rendered.
*/
nodeRef?: React.MutableRefObject<HTMLElement | null>;
/**
* Extra configurations for actions.
*/
metadata?: Record<string, unknown>;
}
export enum CellActionsMode {
HOVER = 'hover',
INLINE = 'inline',
}
export interface CellActionsProps {
/**
* Common set of properties used by most actions.
*/
field: CellActionField;
/**
* The trigger in which the actions are registered.
*/
triggerId: string;
/**
* UI configuration. Possible options are `HOVER` and `INLINE`.
*
* `HOVER` shows the actions when the children component is hovered.
*
* `INLINE` always shows the actions.
*/
mode: CellActionsMode;
/**
* It displays a tooltip for every action button when `true`.
*/
showActionTooltips?: boolean;
/**
* It shows 'more actions' button when the number of actions is bigger than this parameter.
*/
visibleCellActions?: number;
/**
* Custom set of properties used by some actions.
* An action might require a specific set of metadata properties to render.
* This data is sent directly to actions.
*/
metadata?: Record<string, unknown>;
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop",
"@testing-library/jest-dom",
"@testing-library/react"
]
},
"include": ["**/*.ts", "**/*.tsx"],
"kbn_references": [
"@kbn/ui-theme",
"@kbn/i18n",
"@kbn/ui-actions-plugin",
],
"exclude": ["target/**/*"]
}

View file

@ -11,6 +11,7 @@ export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
cases: 'packages/kbn-cases-components/.storybook',
cell_actions: 'packages/kbn-cell-actions/.storybook',
ci_composite: '.ci/.storybook',
cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook',
coloring: 'packages/kbn-coloring/.storybook',

View file

@ -6,6 +6,7 @@ cd "$KIBANA_DIR"
yarn storybook --site apm
yarn storybook --site canvas
yarn storybook --site cell_actions
yarn storybook --site ci_composite
yarn storybook --site content_management
yarn storybook --site custom_integrations

View file

@ -92,6 +92,8 @@
"@kbn/cases-fixture-plugin/*": ["x-pack/test/functional_with_es_ssl/plugins/cases/*"],
"@kbn/cases-plugin": ["x-pack/plugins/cases"],
"@kbn/cases-plugin/*": ["x-pack/plugins/cases/*"],
"@kbn/cell-actions": ["packages/kbn-cell-actions"],
"@kbn/cell-actions/*": ["packages/kbn-cell-actions/*"],
"@kbn/chart-expressions-common": ["src/plugins/chart_expressions/common"],
"@kbn/chart-expressions-common/*": ["src/plugins/chart_expressions/common/*"],
"@kbn/chart-icons": ["packages/kbn-chart-icons"],

View file

@ -2825,6 +2825,10 @@
version "0.0.0"
uid ""
"@kbn/cell-actions@link:packages/kbn-cell-actions":
version "0.0.0"
uid ""
"@kbn/chart-expressions-common@link:src/plugins/chart_expressions/common":
version "0.0.0"
uid ""