diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.ts b/.buildkite/scripts/steps/storybooks/build_and_upload.ts index e315c7fbcceba..d15e15800821a 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.ts +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.ts @@ -41,6 +41,7 @@ const STORYBOOKS = [ 'security_solution', 'shared_ux', 'triggers_actions_ui', + 'ui_actions', 'ui_actions_enhanced', 'language_documentation_popover', 'unified_search', diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 7c37284bc3e77..05ae1c3048d17 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -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', }; diff --git a/src/plugins/ui_actions/.storybook/main.js b/src/plugins/ui_actions/.storybook/main.js new file mode 100644 index 0000000000000..0aaf1046299de --- /dev/null +++ b/src/plugins/ui_actions/.storybook/main.js @@ -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, + }, +}; diff --git a/src/plugins/ui_actions/public/cell_actions/README.md b/src/plugins/ui_actions/public/cell_actions/README.md new file mode 100644 index 0000000000000..aca9ee7082ad5 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/README.md @@ -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 + + ... + + Hover me + + + +``` + +`CellActions` component will display all compatible actions registered for the trigger id. \ No newline at end of file diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.test.tsx new file mode 100644 index 0000000000000..be2f57623419b --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.test.tsx @@ -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( + + ); + 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( + + ); + + expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull(); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.tsx new file mode 100644 index 0000000000000..1b1e57c04cbeb --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_action_item.tsx @@ -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 ? ( + + + + ) : ( + + ); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_actions.stories.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.stories.tsx new file mode 100644 index 0000000000000..4b3e4215bd266 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.stories.tsx @@ -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) => ( + +
+ {storyFn()} + + ), + ], +}; + +const CellActionsTemplate: ComponentStory> = (args) => ( + Field value +); + +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 = ({}: {}) => ( + + Field value + +); + +export const CellActionHoverPopup = ({}: {}) => ( + + Hover me + +); diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_actions.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.test.tsx new file mode 100644 index 0000000000000..a9772643d24d8 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.test.tsx @@ -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( + + + Field value + + + ); + + 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( + + + Field value + + + ); + + 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( + + + Field value + + + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument(); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_actions.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.tsx new file mode 100644 index 0000000000000..83076d30e9965 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_actions.tsx @@ -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; + + /** + * Ref to the node where the cell action are rendered. + */ + nodeRef: React.MutableRefObject; + + /** + * Extra configurations for actions. + */ + metadata?: Record; + + 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; +} + +export const CellActions: React.FC = ({ + field, + triggerId, + children, + mode, + showActionTooltips = true, + visibleCellActions = 3, + metadata, +}) => { + const extraContentNodeRef = useRef(null); + const nodeRef = useRef(null); + + const actionContext: CellActionExecutionContext = useMemo( + () => ({ + field, + trigger: { id: triggerId }, + extraContentNodeRef, + nodeRef, + metadata, + }), + [field, triggerId, metadata] + ); + + if (mode === CellActionsMode.HOVER_POPOVER) { + return ( +
+ + {children} + + +
+
+ ); + } + + return ( +
+ {children} + +
+
+ ); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.test.tsx new file mode 100644 index 0000000000000..6ab425294bf71 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.test.tsx @@ -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 }) => ( + + {children} + + ), + } + ); + + 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 }) => ( + + {children} + + ), + } + ); + + 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 }) => ( + + {children} + + ), + } + ); + + 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 }) => ( + + {children} + + ), + } + ); + + 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 }) => ( + + {children} + + ), + } + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(result.current.value).toEqual([secondAction, thirdAction, actionWithoutOrder]); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.tsx b/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.tsx new file mode 100644 index 0000000000000..8d1d2f0f709cf --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/cell_actions_context.tsx @@ -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; + +type GetActionsType = (context: CellActionExecutionContext) => Promise; + +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 = ({ + children, + getTriggerCompatibleActions, +}) => { + const getSortedCompatibleActions = useCallback( + (context) => + getTriggerCompatibleActions(context.trigger.id, context).then((actions) => + orderBy(['order', 'id'], ['asc', 'asc'], actions) + ), + [getTriggerCompatibleActions] + ); + + return ( + + {children} + + ); +}; + +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, []); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.test.tsx new file mode 100644 index 0000000000000..0fcc81a9cc1c9 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.test.tsx @@ -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( {}} showTooltip={false} />); + + expect(queryByTestId('showExtraActionsButton')).toBeInTheDocument(); + }); + + it('renders tooltip when showTooltip=true is received', () => { + const { container } = render( {}} showTooltip />); + expect(container.querySelector('.euiToolTipAnchor')).not.toBeNull(); + }); + + it('calls onClick when button is clicked', () => { + const onClick = jest.fn(); + const { getByTestId } = render(); + + fireEvent.click(getByTestId('showExtraActionsButton')); + expect(onClick).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.tsx b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.tsx new file mode 100644 index 0000000000000..e70a28e5db4e3 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_button.tsx @@ -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 = ({ onClick, showTooltip }) => + showTooltip ? ( + + + + ) : ( + + ); diff --git a/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.test.tsx new file mode 100644 index 0000000000000..07a0255d06231 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.test.tsx @@ -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( + {}} + actions={[]} + button={} + /> + ); + + 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( + } + /> + ); + + 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( + {}} + 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( + + ); + + fireEvent.click(getByLabelText('test-action')); + + expect(executeAction).toHaveBeenCalled(); + expect(closePopOver).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.tsx b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.tsx new file mode 100644 index 0000000000000..a4e12621f71e4 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/extra_actions_popover.tsx @@ -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 = ({ + actions, + actionContext, + isOpen, + closePopOver, + button, +}) => ( + + + +); + +interface ExtraActionsPopOverWithAnchorProps + extends Pick { + anchorRef: React.RefObject; +} + +export const ExtraActionsPopOverWithAnchor = ({ + anchorRef, + actionContext, + isOpen, + closePopOver, + actions, +}: ExtraActionsPopOverWithAnchorProps) => { + return anchorRef.current ? ( + + + + ) : null; +}; + +type ExtraActionsPopOverContentProps = Pick< + ActionsPopOverProps, + 'actionContext' | 'closePopOver' | 'actions' +>; + +const ExtraActionsPopOverContent: React.FC = ({ + actionContext, + actions, + closePopOver, +}) => { + const items = useMemo( + () => + actions.map((action) => ( + { + closePopOver(); + action.execute(actionContext); + }} + > + {action.getDisplayName(actionContext)} + + )), + [actionContext, actions, closePopOver] + ); + return ( + <> + +

{YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(actionContext.field.name)}

+
+ + + ); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.test.tsx new file mode 100644 index 0000000000000..307d71e115299 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.test.tsx @@ -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 = () => ; + jest.useFakeTimers(); + + it('renders', () => { + const getActions = () => Promise.resolve([]); + const { queryByTestId } = render( + + + + ); + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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( + + + + + + ); + + 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) => { + await act(async () => { + fireEvent.mouseEnter(element); + await waitForChange(); + }); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.tsx b/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.tsx new file mode 100644 index 0000000000000..b01db62172f1a --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/hover_actions_popover.tsx @@ -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( + ({ children, visibleCellActions, actionContext, showActionTooltips }) => { + const contentRef = useRef(null); + const [isExtraActionsPopoverOpen, setIsExtraActionsPopoverOpen] = useState(false); + const [showHoverContent, setShowHoverContent] = useState(false); + const popoverRef = useRef(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 +
+ {children} +
+ ); + }, [onMouseEnter, closeExtraActions, children]); + + return ( + <> +
+ + {showHoverContent ? ( +
+ +

{YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(actionContext.field.name)}

+
+ {visibleActions.map((action) => ( + + ))} + {extraActions.length > 0 ? ( + + ) : null} +
+ ) : null} +
+
+ + + ); + } +); diff --git a/src/plugins/ui_actions/public/cell_actions/components/index.tsx b/src/plugins/ui_actions/public/cell_actions/components/index.tsx new file mode 100644 index 0000000000000..fe75b51e9af3f --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/index.tsx @@ -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'; diff --git a/src/plugins/ui_actions/public/cell_actions/components/inline_actions.test.tsx b/src/plugins/ui_actions/public/cell_actions/components/inline_actions.test.tsx new file mode 100644 index 0000000000000..d9147668b6b3f --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/inline_actions.test.tsx @@ -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( + + + + ); + + 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( + + + + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(queryAllByRole('button').length).toBe(5); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/components/inline_actions.tsx b/src/plugins/ui_actions/public/cell_actions/components/inline_actions.tsx new file mode 100644 index 0000000000000..0133b87e64392 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/inline_actions.tsx @@ -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 = ({ + 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( + () => , + [togglePopOver, showActionTooltips] + ); + + return ( + + {visibleActions.map((action, index) => ( + + ))} + {extraActions.length > 0 ? ( + + ) : null} + + ); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/components/translations.ts b/src/plugins/ui_actions/public/cell_actions/components/translations.ts new file mode 100644 index 0000000000000..272ebcb0cf334 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/components/translations.ts @@ -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', +}); diff --git a/src/plugins/ui_actions/public/cell_actions/hooks/actions.test.ts b/src/plugins/ui_actions/public/cell_actions/hooks/actions.test.ts new file mode 100644 index 0000000000000..a7ca564570e2c --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/hooks/actions.test.ts @@ -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); + }); +}); diff --git a/src/plugins/ui_actions/public/cell_actions/hooks/actions.ts b/src/plugins/ui_actions/public/cell_actions/hooks/actions.ts new file mode 100644 index 0000000000000..84829a36d81bf --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/hooks/actions.ts @@ -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>; + visibleActions: Array>; +} + +export const usePartitionActions = ( + allActions: Action[], + visibleCellActions: number +): PartitionedActions => { + return useMemo(() => { + return partitionActions(allActions ?? [], visibleCellActions); + }, [allActions, visibleCellActions]); +}; diff --git a/src/plugins/ui_actions/public/cell_actions/mocks/helpers.ts b/src/plugins/ui_actions/public/cell_actions/mocks/helpers.ts new file mode 100644 index 0000000000000..c97b89ef505d8 --- /dev/null +++ b/src/plugins/ui_actions/public/cell_actions/mocks/helpers.ts @@ -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(); + }, +}); diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 29598b1c51b9b..8f6702e47dfb0 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -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';