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

## Summary

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

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

*** This component is still not in use.

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


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


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



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

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

```

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



### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
This commit is contained in:
Pablo Machado 2022-12-20 10:00:30 +01:00 committed by GitHub
parent 3cfada5a03
commit ed1f9650d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1593 additions and 0 deletions

View file

@ -41,6 +41,7 @@ const STORYBOOKS = [
'security_solution',
'shared_ux',
'triggers_actions_ui',
'ui_actions',
'ui_actions_enhanced',
'language_documentation_popover',
'unified_search',

View file

@ -45,5 +45,6 @@ export const storybookAliases = {
threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook',
triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook',
ui_actions_enhanced: 'src/plugins/ui_actions_enhanced/.storybook',
ui_actions: 'src/plugins/ui_actions/.storybook',
unified_search: 'src/plugins/unified_search/.storybook',
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
const defaultConfig = require('@kbn/storybook').defaultConfig;
module.exports = {
...defaultConfig,
stories: ['../**/*.stories.tsx'],
reactOptions: {
strictMode: true,
},
};

View file

@ -0,0 +1,17 @@
This package provides a uniform interface for displaying UI actions for a cell.
For the `CellActions` component to work, it must be wrapped by `CellActionsContextProvider`. Ideally, the wrapper should stay on the top of the rendering tree.
Example:
```JSX
<CellActionsContextProvider
// call uiActions.getTriggerCompatibleActions(triggerId, data)
getCompatibleActions={getCompatibleActions}>
...
<CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={MY_TRIGGER_ID} config={{ field: 'fieldName', value: 'fieldValue', fieldType: 'text' }}>
Hover me
</CellActions>
</CellActionsContextProvider>
```
`CellActions` component will display all compatible actions registered for the trigger id.

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 './cell_actions';
import { ActionItem } from './cell_action_item';
describe('ActionItem', () => {
it('renders', () => {
const action = makeAction('test-action');
const actionContext = {} as CellActionExecutionContext;
const { queryByTestId } = render(
<ActionItem action={action} actionContext={actionContext} showTooltip={false} />
);
expect(queryByTestId('actionItem-test-action')).toBeInTheDocument();
});
it('renders tooltip when showTooltip=true is received', () => {
const action = makeAction('test-action');
const actionContext = {} as CellActionExecutionContext;
const { container } = render(
<ActionItem action={action} actionContext={actionContext} showTooltip />
);
expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull();
});
});

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useMemo } from 'react';
import { EuiButtonIcon, EuiToolTip, IconType } from '@elastic/eui';
import type { Action } from '../../actions';
import { CellActionExecutionContext } from './cell_actions';
export const ActionItem = ({
action,
actionContext,
showTooltip,
}: {
action: Action;
actionContext: CellActionExecutionContext;
showTooltip: boolean;
}) => {
const actionProps = useMemo(
() => ({
iconType: action.getIconType(actionContext) as IconType,
onClick: () => action.execute(actionContext),
'data-test-subj': `actionItem-${action.id}`,
'aria-label': action.getDisplayName(actionContext),
}),
[action, actionContext]
);
if (!actionProps.iconType) return null;
return showTooltip ? (
<EuiToolTip
content={action.getDisplayNameTooltip ? action.getDisplayNameTooltip(actionContext) : ''}
>
<EuiButtonIcon {...actionProps} iconSize="s" />
</EuiToolTip>
) : (
<EuiButtonIcon {...actionProps} iconSize="s" />
);
};

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { ComponentStory } from '@storybook/react';
import { CellActionsContextProvider } from './cell_actions_context';
import { makeAction } from '../mocks/helpers';
import { CellActions, CellActionsMode, CellActionsProps } from './cell_actions';
const TRIGGER_ID = 'testTriggerId';
const FIELD = { name: 'name', value: '123', type: 'text' };
const getCompatibleActions = () =>
Promise.resolve([
makeAction('Filter in', 'plusInCircle', 2),
makeAction('Filter out', 'minusInCircle', 3),
makeAction('Minimize', 'minimize', 1),
makeAction('Send email', 'email', 4),
makeAction('Pin field', 'pin', 5),
]);
export default {
title: 'CellAction',
decorators: [
(storyFn: Function) => (
<CellActionsContextProvider
// call uiActions getTriggerCompatibleActions(triggerId, data)
getTriggerCompatibleActions={getCompatibleActions}
>
<div style={{ paddingTop: '70px' }} />
{storyFn()}
</CellActionsContextProvider>
),
],
};
const CellActionsTemplate: ComponentStory<React.FC<CellActionsProps>> = (args) => (
<CellActions {...args}>Field value</CellActions>
);
export const DefaultWithControls = CellActionsTemplate.bind({});
DefaultWithControls.argTypes = {
mode: {
options: [CellActionsMode.HOVER_POPOVER, CellActionsMode.ALWAYS_VISIBLE],
defaultValue: CellActionsMode.HOVER_POPOVER,
control: {
type: 'radio',
},
},
};
DefaultWithControls.args = {
showActionTooltips: true,
mode: CellActionsMode.ALWAYS_VISIBLE,
triggerId: TRIGGER_ID,
field: FIELD,
visibleCellActions: 3,
};
export const CellActionInline = ({}: {}) => (
<CellActions mode={CellActionsMode.ALWAYS_VISIBLE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
);
export const CellActionHoverPopup = ({}: {}) => (
<CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={TRIGGER_ID} field={FIELD}>
Hover me
</CellActions>
);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { act, render } from '@testing-library/react';
import React from 'react';
import { CellActions, CellActionsMode } from './cell_actions';
import { CellActionsContextProvider } from './cell_actions_context';
const TRIGGER_ID = 'test-trigger-id';
const FIELD = { name: 'name', value: '123', type: 'text' };
describe('CellActions', () => {
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.ALWAYS_VISIBLE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsContextProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(queryByTestId('cellActions')).toBeInTheDocument();
});
it('renders InlineActions when mode is ALWAYS_VISIBLE', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.ALWAYS_VISIBLE} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsContextProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(queryByTestId('inlineActions')).toBeInTheDocument();
});
it('renders HoverActionsPopover when mode is HOVER_POPOVER', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<CellActions mode={CellActionsMode.HOVER_POPOVER} triggerId={TRIGGER_ID} field={FIELD}>
Field value
</CellActions>
</CellActionsContextProvider>
);
await act(async () => {
await getActionsPromise;
});
expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,140 @@
/*
* 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 type { ActionExecutionContext } from '../../actions';
import { InlineActions } from './inline_actions';
import { HoverActionsPopover } from './hover_actions_popover';
export interface CellActionField {
/**
* Field name.
* Example: 'host.name'
*/
name: string;
/**
* Field type.
* Example: 'keyword'
*/
type: string;
/**
* Field value.
* Example: 'My-Laptop'
*/
value: string;
}
export interface CellActionExecutionContext extends ActionExecutionContext {
/**
* 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<HTMLDivElement | null>;
/**
* Extra configurations for actions.
*/
metadata?: Record<string, unknown>;
field: CellActionField;
}
export enum CellActionsMode {
HOVER_POPOVER = 'hover-popover',
ALWAYS_VISIBLE = 'always-visible',
}
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_POPOVER` and `ALWAYS_VISIBLE`.
*
* `HOVER_POPOVER` shows the actions when the children component is hovered.
*
* `ALWAYS_VISIBLE` 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>;
}
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_POPOVER) {
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,155 @@
/*
* 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 React from 'react';
import { makeAction } from '../mocks/helpers';
import { CellActionExecutionContext } from './cell_actions';
import {
CellActionsContextProvider,
useLoadActions,
useLoadActionsFn,
} from './cell_actions_context';
describe('CellActionsContextProvider', () => {
const actionContext = { trigger: { id: 'triggerId' } } as CellActionExecutionContext;
it('loads actions when useLoadActionsFn callback is called', async () => {
const action = makeAction('action-1', 'icon', 1);
const getActionsPromise = Promise.resolve([action]);
const getActions = () => getActionsPromise;
const { result } = renderHook(
() => useLoadActionsFn(),
{
wrapper: ({ children }) => (
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
{children}
</CellActionsContextProvider>
),
}
);
const [{ value: valueBeforeFnCalled }, loadActions] = result.current;
// value is undefined before loadActions is called
expect(valueBeforeFnCalled).toBeUndefined();
await act(async () => {
loadActions(actionContext);
await getActionsPromise;
});
const [{ value: valueAfterFnCalled }] = result.current;
expect(valueAfterFnCalled).toEqual([action]);
});
it('loads actions when useLoadActions called', async () => {
const action = makeAction('action-1', 'icon', 1);
const getActionsPromise = Promise.resolve([action]);
const getActions = () => getActionsPromise;
const { result } = renderHook(
() => useLoadActions(actionContext),
{
wrapper: ({ children }) => (
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
{children}
</CellActionsContextProvider>
),
}
);
await act(async () => {
await getActionsPromise;
});
expect(result.current.value).toEqual([action]);
});
it('sorts actions by order', async () => {
const firstAction = makeAction('action-1', 'icon', 1);
const secondAction = makeAction('action-2', 'icon', 2);
const getActionsPromise = Promise.resolve([secondAction, firstAction]);
const getActions = () => getActionsPromise;
const { result } = renderHook(
() => useLoadActions(actionContext),
{
wrapper: ({ children }) => (
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
{children}
</CellActionsContextProvider>
),
}
);
await act(async () => {
await getActionsPromise;
});
expect(result.current.value).toEqual([firstAction, secondAction]);
});
it('sorts actions by id when order is undefined', async () => {
const firstAction = makeAction('action-1');
const secondAction = makeAction('action-2');
const getActionsPromise = Promise.resolve([secondAction, firstAction]);
const getActions = () => getActionsPromise;
const { result } = renderHook(
() => useLoadActions(actionContext),
{
wrapper: ({ children }) => (
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
{children}
</CellActionsContextProvider>
),
}
);
await act(async () => {
await getActionsPromise;
});
expect(result.current.value).toEqual([firstAction, secondAction]);
});
it('sorts 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);
const getActionsPromise = Promise.resolve([secondAction, actionWithoutOrder, thirdAction]);
const getActions = () => getActionsPromise;
const { result } = renderHook(
() => useLoadActions(actionContext),
{
wrapper: ({ children }) => (
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
{children}
</CellActionsContextProvider>
),
}
);
await act(async () => {
await getActionsPromise;
});
expect(result.current.value).toEqual([secondAction, thirdAction, actionWithoutOrder]);
});
});

View file

@ -0,0 +1,69 @@
/*
* 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 useAsync from 'react-use/lib/useAsync';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import type { Action } from '../../actions';
import { CellActionExecutionContext } from './cell_actions';
// It must to match `UiActionsService.getTriggerCompatibleActions`
type GetTriggerCompatibleActionsType = (triggerId: string, context: object) => Promise<Action[]>;
type GetActionsType = (context: CellActionExecutionContext) => Promise<Action[]>;
const CellActionsContext = createContext<{ getActions: GetActionsType } | null>(null);
interface CellActionsContextProviderProps {
/**
* Please assign `uiActions.getTriggerCompatibleActions` function.
* This function should return a list of actions for a triggerId that are compatible with the provided context.
*/
getTriggerCompatibleActions: GetTriggerCompatibleActionsType;
}
export const CellActionsContextProvider: FC<CellActionsContextProviderProps> = ({
children,
getTriggerCompatibleActions,
}) => {
const getSortedCompatibleActions = useCallback<GetActionsType>(
(context) =>
getTriggerCompatibleActions(context.trigger.id, context).then((actions) =>
orderBy(['order', 'id'], ['asc', 'asc'], actions)
),
[getTriggerCompatibleActions]
);
return (
<CellActionsContext.Provider value={{ getActions: getSortedCompatibleActions }}>
{children}
</CellActionsContext.Provider>
);
};
const useCellActions = () => {
const context = useContext(CellActionsContext);
if (!context) {
throw new Error(
'No CellActionsContext found. Please wrap the application with CellActionsContextProvider'
);
}
return context;
};
export const useLoadActions = (context: CellActionExecutionContext) => {
const { getActions } = useCellActions();
return useAsync(() => getActions(context), []);
};
export const useLoadActionsFn = () => {
const { getActions } = useCellActions();
return useAsyncFn(getActions, []);
};

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,91 @@
/*
* 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 { CellActionExecutionContext } from './cell_actions';
import { makeAction } from '../mocks/helpers';
import { ExtraActionsPopOver, ExtraActionsPopOverWithAnchor } from './extra_actions_popover';
const actionContext = { field: { name: 'fieldName' } } as CellActionExecutionContext;
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,133 @@
/*
* 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 type { Action } from '../../actions';
import { EXTRA_ACTIONS_ARIA_LABEL, YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS } from './translations';
import { CellActionExecutionContext } from './cell_actions';
const euiContextMenuItemCSS = css`
color: ${euiThemeVars.euiColorPrimaryText};
`;
interface ActionsPopOverProps {
actionContext: CellActionExecutionContext;
isOpen: boolean;
closePopOver: () => void;
actions: Action[];
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,195 @@
/*
* 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 { CellActionExecutionContext } from './cell_actions';
import { makeAction } from '../mocks/helpers';
import { HoverActionsPopover } from './hover_actions_popover';
import { CellActionsContextProvider } from './cell_actions_context';
describe('HoverActionsPopover', () => {
const actionContext = {
trigger: { id: 'triggerId' },
field: { name: 'fieldName' },
} as CellActionExecutionContext;
const TestComponent = () => <span data-test-subj="test-component" />;
jest.useFakeTimers();
it('renders', () => {
const getActions = () => Promise.resolve([]);
const { queryByTestId } = render(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
children={null}
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={4}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={1}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<HoverActionsPopover
visibleCellActions={2}
actionContext={actionContext}
showActionTooltips={false}
>
<TestComponent />
</HoverActionsPopover>
</CellActionsContextProvider>
);
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 './cell_actions';
import { useLoadActionsFn } from './cell_actions_context';
/** 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,10 @@
/*
* 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, CellActionsMode } from './cell_actions';
export { CellActionsContextProvider } from './cell_actions_context';

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 { CellActionExecutionContext } from './cell_actions';
import { makeAction } from '../mocks/helpers';
import { InlineActions } from './inline_actions';
import { CellActionsContextProvider } from '.';
describe('InlineActions', () => {
const actionContext = { trigger: { id: 'triggerId' } } as CellActionExecutionContext;
it('renders', async () => {
const getActionsPromise = Promise.resolve([]);
const getActions = () => getActionsPromise;
const { queryByTestId } = render(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<InlineActions
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsContextProvider>
);
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(
<CellActionsContextProvider getTriggerCompatibleActions={getActions}>
<InlineActions
visibleCellActions={5}
actionContext={actionContext}
showActionTooltips={false}
/>
</CellActionsContextProvider>
);
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 { CellActionExecutionContext } from './cell_actions';
import { useLoadActions } from './cell_actions_context';
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,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 { i18n } from '@kbn/i18n';
export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) =>
i18n.translate('uiActions.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(
'uiActions.cellActions.extraActionsAriaLabel',
{
defaultMessage: 'Extra actions',
}
);
export const SHOW_MORE_ACTIONS = i18n.translate('uiActions.showMoreActionsLabel', {
defaultMessage: 'More actions',
});
export const ACTIONS_AREA_LABEL = i18n.translate('uiActions.cellActions.actionsAriaLabel', {
defaultMessage: 'Actions',
});

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,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 { useMemo } from 'react';
import { Action } from '../../actions';
export const partitionActions = (actions: Action[], 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 interface PartitionedActions {
extraActions: Array<Action<object>>;
visibleActions: Array<Action<object>>;
}
export const usePartitionActions = (
allActions: Action[],
visibleCellActions: number
): PartitionedActions => {
return useMemo(() => {
return partitionActions(allActions ?? [], visibleCellActions);
}, [allActions, visibleCellActions]);
};

View file

@ -0,0 +1,21 @@
/*
* 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 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();
},
});

View file

@ -39,3 +39,8 @@ export {
ACTION_VISUALIZE_LENS_FIELD,
} from './types';
export type { ActionExecutionContext, ActionExecutionMeta, ActionMenuItemProps } from './actions';
export {
CellActions,
CellActionsMode,
CellActionsContextProvider,
} from './cell_actions/components';