mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
3cfada5a03
commit
ed1f9650d1
25 changed files with 1593 additions and 0 deletions
|
@ -41,6 +41,7 @@ const STORYBOOKS = [
|
|||
'security_solution',
|
||||
'shared_ux',
|
||||
'triggers_actions_ui',
|
||||
'ui_actions',
|
||||
'ui_actions_enhanced',
|
||||
'language_documentation_popover',
|
||||
'unified_search',
|
||||
|
|
|
@ -45,5 +45,6 @@ export const storybookAliases = {
|
|||
threat_intelligence: 'x-pack/plugins/threat_intelligence/.storybook',
|
||||
triggers_actions_ui: 'x-pack/plugins/triggers_actions_ui/.storybook',
|
||||
ui_actions_enhanced: 'src/plugins/ui_actions_enhanced/.storybook',
|
||||
ui_actions: 'src/plugins/ui_actions/.storybook',
|
||||
unified_search: 'src/plugins/unified_search/.storybook',
|
||||
};
|
||||
|
|
17
src/plugins/ui_actions/.storybook/main.js
Normal file
17
src/plugins/ui_actions/.storybook/main.js
Normal 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,
|
||||
},
|
||||
};
|
17
src/plugins/ui_actions/public/cell_actions/README.md
Normal file
17
src/plugins/ui_actions/public/cell_actions/README.md
Normal 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.
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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" />
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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, []);
|
||||
};
|
|
@ -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,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();
|
||||
});
|
||||
});
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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',
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
34
src/plugins/ui_actions/public/cell_actions/hooks/actions.ts
Normal file
34
src/plugins/ui_actions/public/cell_actions/hooks/actions.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 { 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]);
|
||||
};
|
21
src/plugins/ui_actions/public/cell_actions/mocks/helpers.ts
Normal file
21
src/plugins/ui_actions/public/cell_actions/mocks/helpers.ts
Normal 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();
|
||||
},
|
||||
});
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue