mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
fabfb43740
commit
bca73b72ed
42 changed files with 1998 additions and 0 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
9
packages/kbn-cell-actions/.storybook/main.js
Normal file
9
packages/kbn-cell-actions/.storybook/main.js
Normal 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;
|
15
packages/kbn-cell-actions/README.md
Normal file
15
packages/kbn-cell-actions/README.md
Normal 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.
|
9
packages/kbn-cell-actions/index.ts
Normal file
9
packages/kbn-cell-actions/index.ts
Normal 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';
|
13
packages/kbn-cell-actions/jest.config.js
Normal file
13
packages/kbn-cell-actions/jest.config.js
Normal 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'],
|
||||
};
|
5
packages/kbn-cell-actions/kibana.jsonc
Normal file
5
packages/kbn-cell-actions/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/cell-actions",
|
||||
"owner": "@elastic/security-threat-hunting-explore"
|
||||
}
|
7
packages/kbn-cell-actions/package.json
Normal file
7
packages/kbn-cell-actions/package.json
Normal 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
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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" />
|
||||
);
|
||||
};
|
|
@ -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
packages/kbn-cell-actions/src/components/cell_actions.tsx
Normal file
64
packages/kbn-cell-actions/src/components/cell_actions.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
9
packages/kbn-cell-actions/src/components/index.tsx
Normal file
9
packages/kbn-cell-actions/src/components/index.tsx
Normal 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';
|
|
@ -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);
|
||||
});
|
||||
});
|
62
packages/kbn-cell-actions/src/components/inline_actions.tsx
Normal file
62
packages/kbn-cell-actions/src/components/inline_actions.tsx
Normal 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>
|
||||
);
|
||||
};
|
26
packages/kbn-cell-actions/src/components/translations.ts
Normal file
26
packages/kbn-cell-actions/src/components/translations.ts
Normal 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',
|
||||
});
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
9
packages/kbn-cell-actions/src/context/index.ts
Normal file
9
packages/kbn-cell-actions/src/context/index.ts
Normal 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';
|
85
packages/kbn-cell-actions/src/hooks/actions.test.ts
Normal file
85
packages/kbn-cell-actions/src/hooks/actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
29
packages/kbn-cell-actions/src/hooks/actions.ts
Normal file
29
packages/kbn-cell-actions/src/hooks/actions.ts
Normal 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]);
|
||||
};
|
12
packages/kbn-cell-actions/src/hooks/index.ts
Normal file
12
packages/kbn-cell-actions/src/hooks/index.ts
Normal 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';
|
|
@ -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] },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
85
packages/kbn-cell-actions/src/hooks/use_load_actions.test.ts
Normal file
85
packages/kbn-cell-actions/src/hooks/use_load_actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
36
packages/kbn-cell-actions/src/hooks/use_load_actions.ts
Normal file
36
packages/kbn-cell-actions/src/hooks/use_load_actions.ts
Normal 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))), []);
|
||||
};
|
13
packages/kbn-cell-actions/src/index.ts
Normal file
13
packages/kbn-cell-actions/src/index.ts
Normal 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';
|
34
packages/kbn-cell-actions/src/mocks/helpers.ts
Normal file
34
packages/kbn-cell-actions/src/mocks/helpers.ts
Normal 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,
|
||||
});
|
105
packages/kbn-cell-actions/src/types.ts
Normal file
105
packages/kbn-cell-actions/src/types.ts
Normal 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>;
|
||||
}
|
21
packages/kbn-cell-actions/tsconfig.json
Normal file
21
packages/kbn-cell-actions/tsconfig.json
Normal 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/**/*"]
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue