diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 19443a7c76666..0481f6e31a6c3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -896,6 +896,7 @@ packages/kbn-babel-register @elastic/kibana-operations packages/kbn-babel-transform @elastic/kibana-operations packages/kbn-bazel-runner @elastic/kibana-operations packages/kbn-cases-components @elastic/response-ops +packages/kbn-cell-actions @elastic/security-threat-hunting-explore packages/kbn-chart-icons @elastic/kibana-visualizations packages/kbn-ci-stats-core @elastic/kibana-operations packages/kbn-ci-stats-performance-metrics @elastic/kibana-operations diff --git a/.i18nrc.json b/.i18nrc.json index 3c95e9b514484..319c3e45aca47 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -6,6 +6,7 @@ "autocomplete": "packages/kbn-securitysolution-autocomplete/src", "bfetch": "src/plugins/bfetch", "cases": ["packages/kbn-cases-components"], + "cellActions": "packages/kbn-cell-actions", "charts": "src/plugins/charts", "console": "src/plugins/console", "contentManagement": "packages/content-management", diff --git a/package.json b/package.json index 9e837fd629791..5b178cfec1742 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "@kbn/apm-config-loader": "link:packages/kbn-apm-config-loader", "@kbn/apm-utils": "link:packages/kbn-apm-utils", "@kbn/cases-components": "link:packages/kbn-cases-components", + "@kbn/cell-actions": "link:packages/kbn-cell-actions", "@kbn/chart-expressions-common": "link:src/plugins/chart_expressions/common", "@kbn/chart-icons": "link:packages/kbn-chart-icons", "@kbn/coloring": "link:packages/kbn-coloring", diff --git a/packages/kbn-cell-actions/.storybook/main.js b/packages/kbn-cell-actions/.storybook/main.js new file mode 100644 index 0000000000000..8dc3c5d1518f4 --- /dev/null +++ b/packages/kbn-cell-actions/.storybook/main.js @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = require('@kbn/storybook').defaultConfig; diff --git a/packages/kbn-cell-actions/README.md b/packages/kbn-cell-actions/README.md new file mode 100644 index 0000000000000..7cacff6becda6 --- /dev/null +++ b/packages/kbn-cell-actions/README.md @@ -0,0 +1,15 @@ +This package provides a uniform interface for displaying UI actions for a cell. +For the `CellActions` component to work, it must be wrapped by `CellActionsProvider`. Ideally, the wrapper should stay on the top of the rendering tree. + +Example: + +```JSX + + [...] + + Hover me + + +``` + +`CellActions` component will display all compatible actions registered for the trigger id. diff --git a/packages/kbn-cell-actions/index.ts b/packages/kbn-cell-actions/index.ts new file mode 100644 index 0000000000000..de0577ee3ed83 --- /dev/null +++ b/packages/kbn-cell-actions/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export * from './src'; diff --git a/packages/kbn-cell-actions/jest.config.js b/packages/kbn-cell-actions/jest.config.js new file mode 100644 index 0000000000000..b9301a9500864 --- /dev/null +++ b/packages/kbn-cell-actions/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../..', + roots: ['/packages/kbn-cell-actions'], +}; diff --git a/packages/kbn-cell-actions/kibana.jsonc b/packages/kbn-cell-actions/kibana.jsonc new file mode 100644 index 0000000000000..e1ce1385436b3 --- /dev/null +++ b/packages/kbn-cell-actions/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/cell-actions", + "owner": "@elastic/security-threat-hunting-explore" +} diff --git a/packages/kbn-cell-actions/package.json b/packages/kbn-cell-actions/package.json new file mode 100644 index 0000000000000..f216094e0a710 --- /dev/null +++ b/packages/kbn-cell-actions/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/cell-actions", + "version": "1.0.0", + "description": "Uniform components for displaying UI actions in data cells", + "license": "SSPL-1.0 OR Elastic License 2.0", + "private": true +} diff --git a/packages/kbn-cell-actions/src/__stories__/cell_actions.stories.tsx b/packages/kbn-cell-actions/src/__stories__/cell_actions.stories.tsx new file mode 100644 index 0000000000000..4c0f362d3ddf1 --- /dev/null +++ b/packages/kbn-cell-actions/src/__stories__/cell_actions.stories.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ComponentStory } from '@storybook/react'; +import { CellActionsProvider } from '../context/cell_actions_context'; +import { makeAction } from '../mocks/helpers'; +import { CellActions } from '../components/cell_actions'; +import { CellActionsMode, type CellActionsProps } from '../types'; + +const TRIGGER_ID = 'testTriggerId'; + +const FIELD = { name: 'name', value: '123', type: 'text' }; + +const getCompatibleActions = () => + Promise.resolve([ + makeAction('Filter in', 'plusInCircle', 2), + makeAction('Filter out', 'minusInCircle', 3), + makeAction('Minimize', 'minimize', 1), + makeAction('Send email', 'email', 4), + makeAction('Pin field', 'pin', 5), + ]); + +export default { + title: 'CellAction', + decorators: [ + (storyFn: Function) => ( + +
+ {storyFn()} + + ), + ], +}; + +const CellActionsTemplate: ComponentStory> = (args) => ( + Field value +); + +export const DefaultWithControls = CellActionsTemplate.bind({}); + +DefaultWithControls.argTypes = { + mode: { + options: [CellActionsMode.HOVER, CellActionsMode.INLINE], + defaultValue: CellActionsMode.HOVER, + control: { + type: 'radio', + }, + }, +}; + +DefaultWithControls.args = { + showActionTooltips: true, + mode: CellActionsMode.INLINE, + triggerId: TRIGGER_ID, + field: FIELD, + visibleCellActions: 3, +}; + +export const CellActionInline = ({}: {}) => ( + + Field value + +); + +export const CellActionHoverPopup = ({}: {}) => ( + + Hover me + +); diff --git a/packages/kbn-cell-actions/src/components/cell_action_item.test.tsx b/packages/kbn-cell-actions/src/components/cell_action_item.test.tsx new file mode 100644 index 0000000000000..ab56f083f7365 --- /dev/null +++ b/packages/kbn-cell-actions/src/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 '../types'; +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/packages/kbn-cell-actions/src/components/cell_action_item.tsx b/packages/kbn-cell-actions/src/components/cell_action_item.tsx new file mode 100644 index 0000000000000..b002afb35d83f --- /dev/null +++ b/packages/kbn-cell-actions/src/components/cell_action_item.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo } from 'react'; + +import { EuiButtonIcon, EuiToolTip, IconType } from '@elastic/eui'; +import type { CellAction, CellActionExecutionContext } from '../types'; + +export const ActionItem = ({ + action, + actionContext, + showTooltip, +}: { + action: CellAction; + actionContext: CellActionExecutionContext; + showTooltip: boolean; +}) => { + const actionProps = useMemo( + () => ({ + iconType: action.getIconType(actionContext) as IconType, + onClick: () => action.execute(actionContext), + 'data-test-subj': `actionItem-${action.id}`, + 'aria-label': action.getDisplayName(actionContext), + }), + [action, actionContext] + ); + + if (!actionProps.iconType) return null; + + return showTooltip ? ( + + + + ) : ( + + ); +}; diff --git a/packages/kbn-cell-actions/src/components/cell_actions.test.tsx b/packages/kbn-cell-actions/src/components/cell_actions.test.tsx new file mode 100644 index 0000000000000..d23d7f731b156 --- /dev/null +++ b/packages/kbn-cell-actions/src/components/cell_actions.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { CellActions } from './cell_actions'; +import { CellActionsMode } from '../types'; +import { CellActionsProvider } from '../context/cell_actions_context'; + +const TRIGGER_ID = 'test-trigger-id'; +const FIELD = { name: 'name', value: '123', type: 'text' }; + +describe('CellActions', () => { + it('renders', async () => { + const getActionsPromise = Promise.resolve([]); + const getActions = () => getActionsPromise; + + const { queryByTestId } = render( + + + Field value + + + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(queryByTestId('cellActions')).toBeInTheDocument(); + }); + + it('renders InlineActions when mode is INLINE', async () => { + const getActionsPromise = Promise.resolve([]); + const getActions = () => getActionsPromise; + + const { queryByTestId } = render( + + + Field value + + + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(queryByTestId('inlineActions')).toBeInTheDocument(); + }); + + it('renders HoverActionsPopover when mode is HOVER', async () => { + const getActionsPromise = Promise.resolve([]); + const getActions = () => getActionsPromise; + + const { queryByTestId } = render( + + + Field value + + + ); + + await act(async () => { + await getActionsPromise; + }); + + expect(queryByTestId('hoverActionsPopover')).toBeInTheDocument(); + }); +}); diff --git a/packages/kbn-cell-actions/src/components/cell_actions.tsx b/packages/kbn-cell-actions/src/components/cell_actions.tsx new file mode 100644 index 0000000000000..682233eaa76b7 --- /dev/null +++ b/packages/kbn-cell-actions/src/components/cell_actions.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useRef } from 'react'; +import { InlineActions } from './inline_actions'; +import { HoverActionsPopover } from './hover_actions_popover'; +import { CellActionsMode, type CellActionsProps, type CellActionExecutionContext } from '../types'; + +export const CellActions: React.FC = ({ + 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) { + return ( +
+ + {children} + + +
+
+ ); + } + + return ( +
+ {children} + +
+
+ ); +}; diff --git a/packages/kbn-cell-actions/src/components/extra_actions_button.test.tsx b/packages/kbn-cell-actions/src/components/extra_actions_button.test.tsx new file mode 100644 index 0000000000000..0fcc81a9cc1c9 --- /dev/null +++ b/packages/kbn-cell-actions/src/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/packages/kbn-cell-actions/src/components/extra_actions_button.tsx b/packages/kbn-cell-actions/src/components/extra_actions_button.tsx new file mode 100644 index 0000000000000..e70a28e5db4e3 --- /dev/null +++ b/packages/kbn-cell-actions/src/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/packages/kbn-cell-actions/src/components/extra_actions_popover.test.tsx b/packages/kbn-cell-actions/src/components/extra_actions_popover.test.tsx new file mode 100644 index 0000000000000..b463077bceed6 --- /dev/null +++ b/packages/kbn-cell-actions/src/components/extra_actions_popover.test.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { makeAction, makeActionContext } from '../mocks/helpers'; +import { ExtraActionsPopOver, ExtraActionsPopOverWithAnchor } from './extra_actions_popover'; + +const actionContext = makeActionContext(); +describe('ExtraActionsPopOver', () => { + it('renders', () => { + const { queryByTestId } = render( + {}} + 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/packages/kbn-cell-actions/src/components/extra_actions_popover.tsx b/packages/kbn-cell-actions/src/components/extra_actions_popover.tsx new file mode 100644 index 0000000000000..4ed1c0d629dcd --- /dev/null +++ b/packages/kbn-cell-actions/src/components/extra_actions_popover.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, + EuiScreenReaderOnly, + EuiWrappingPopover, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { css } from '@emotion/react'; +import { EXTRA_ACTIONS_ARIA_LABEL, YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS } from './translations'; +import type { CellAction, CellActionExecutionContext } from '../types'; + +const euiContextMenuItemCSS = css` + color: ${euiThemeVars.euiColorPrimaryText}; +`; + +interface ActionsPopOverProps { + actionContext: CellActionExecutionContext; + isOpen: boolean; + closePopOver: () => void; + actions: CellAction[]; + button: JSX.Element; +} + +export const ExtraActionsPopOver: React.FC = ({ + 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/packages/kbn-cell-actions/src/components/hover_actions_popover.test.tsx b/packages/kbn-cell-actions/src/components/hover_actions_popover.test.tsx new file mode 100644 index 0000000000000..8326b4a70f366 --- /dev/null +++ b/packages/kbn-cell-actions/src/components/hover_actions_popover.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { act, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { makeAction, makeActionContext } from '../mocks/helpers'; +import { HoverActionsPopover } from './hover_actions_popover'; +import { CellActionsProvider } from '../context/cell_actions_context'; + +describe('HoverActionsPopover', () => { + const actionContext = makeActionContext(); + const TestComponent = () => ; + 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/packages/kbn-cell-actions/src/components/hover_actions_popover.tsx b/packages/kbn-cell-actions/src/components/hover_actions_popover.tsx new file mode 100644 index 0000000000000..a6201159d0a0e --- /dev/null +++ b/packages/kbn-cell-actions/src/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 '../types'; +import { useLoadActionsFn } from '../hooks/use_load_actions'; + +/** This class is added to the document body while dragging */ +export const IS_DRAGGING_CLASS_NAME = 'is-dragging'; + +// Overwrite Popover default minWidth to avoid displaying empty space +const PANEL_STYLE = { minWidth: `24px` }; + +const hoverContentWrapperCSS = css` + padding: 0 ${euiThemeVars.euiSizeS}; +`; + +/** + * To avoid expensive changes to the DOM, delay showing the popover menu + */ +const HOVER_INTENT_DELAY = 100; // ms + +interface Props { + children: React.ReactNode; + visibleCellActions: number; + actionContext: CellActionExecutionContext; + showActionTooltips: boolean; +} + +export const HoverActionsPopover = React.memo( + ({ 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/packages/kbn-cell-actions/src/components/index.tsx b/packages/kbn-cell-actions/src/components/index.tsx new file mode 100644 index 0000000000000..2f7fd950a6f4c --- /dev/null +++ b/packages/kbn-cell-actions/src/components/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CellActions } from './cell_actions'; diff --git a/packages/kbn-cell-actions/src/components/inline_actions.test.tsx b/packages/kbn-cell-actions/src/components/inline_actions.test.tsx new file mode 100644 index 0000000000000..91733929e8b6a --- /dev/null +++ b/packages/kbn-cell-actions/src/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 { makeAction, makeActionContext } from '../mocks/helpers'; +import { InlineActions } from './inline_actions'; +import { CellActionsProvider } from '../context/cell_actions_context'; + +describe('InlineActions', () => { + const actionContext = makeActionContext(); + + it('renders', async () => { + const getActionsPromise = Promise.resolve([]); + const getActions = () => getActionsPromise; + const { queryByTestId } = render( + + + + ); + + 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/packages/kbn-cell-actions/src/components/inline_actions.tsx b/packages/kbn-cell-actions/src/components/inline_actions.tsx new file mode 100644 index 0000000000000..bcf8222ce83ca --- /dev/null +++ b/packages/kbn-cell-actions/src/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 type { CellActionExecutionContext } from '../types'; +import { useLoadActions } from '../hooks/use_load_actions'; + +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/packages/kbn-cell-actions/src/components/translations.ts b/packages/kbn-cell-actions/src/components/translations.ts new file mode 100644 index 0000000000000..1209881f1732c --- /dev/null +++ b/packages/kbn-cell-actions/src/components/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { i18n } from '@kbn/i18n'; + +export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) => + i18n.translate('cellActions.youAreInADialogContainingOptionsScreenReaderOnly', { + values: { fieldName }, + defaultMessage: `You are in a dialog, containing options for field {fieldName}. Press tab to navigate options. Press escape to exit.`, + }); + +export const EXTRA_ACTIONS_ARIA_LABEL = i18n.translate('cellActions.extraActionsAriaLabel', { + defaultMessage: 'Extra actions', +}); + +export const SHOW_MORE_ACTIONS = i18n.translate('cellActions.showMoreActionsLabel', { + defaultMessage: 'More actions', +}); + +export const ACTIONS_AREA_LABEL = i18n.translate('cellActions.actionsAriaLabel', { + defaultMessage: 'Actions', +}); diff --git a/packages/kbn-cell-actions/src/context/cell_actions_context.test.tsx b/packages/kbn-cell-actions/src/context/cell_actions_context.test.tsx new file mode 100644 index 0000000000000..5c9084d0e81dd --- /dev/null +++ b/packages/kbn-cell-actions/src/context/cell_actions_context.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { makeAction, makeActionContext } from '../mocks/helpers'; +import { CellActionsProvider, useCellActionsContext } from './cell_actions_context'; + +const action = makeAction('action-1', 'icon', 1); +const mockGetTriggerCompatibleActions = jest.fn(async () => [action]); +const ContextWrapper: React.FC = ({ children }) => ( + + {children} + +); + +describe('CellActionContext', () => { + const triggerId = 'triggerId'; + const actionContext = makeActionContext(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw error when context not found', () => { + const { result } = renderHook(useCellActionsContext); + expect(result.error).toEqual( + new Error('No CellActionsContext found. Please wrap the application with CellActionsProvider') + ); + }); + + it('should call getTriggerCompatibleActions and return actions', async () => { + const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper }); + const actions = await result.current.getActions(actionContext); + + expect(mockGetTriggerCompatibleActions).toHaveBeenCalledWith(triggerId, actionContext); + expect(actions).toEqual([action]); + }); + + it('should sort actions by order', async () => { + const firstAction = makeAction('action-1', 'icon', 1); + const secondAction = makeAction('action-2', 'icon', 2); + mockGetTriggerCompatibleActions.mockResolvedValueOnce([secondAction, firstAction]); + + const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper }); + const actions = await result.current.getActions(actionContext); + + expect(actions).toEqual([firstAction, secondAction]); + }); + + it('should sort actions by id when order is undefined', async () => { + const firstAction = makeAction('action-1'); + const secondAction = makeAction('action-2'); + + mockGetTriggerCompatibleActions.mockResolvedValueOnce([secondAction, firstAction]); + + const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper }); + const actions = await result.current.getActions(actionContext); + + expect(actions).toEqual([firstAction, secondAction]); + }); + + it('should sort actions by id and order', async () => { + const actionWithoutOrder = makeAction('action-1-no-order'); + const secondAction = makeAction('action-2', 'icon', 2); + const thirdAction = makeAction('action-3', 'icon', 3); + + mockGetTriggerCompatibleActions.mockResolvedValueOnce([ + thirdAction, + secondAction, + actionWithoutOrder, + ]); + + const { result } = renderHook(useCellActionsContext, { wrapper: ContextWrapper }); + const actions = await result.current.getActions(actionContext); + + expect(actions).toEqual([secondAction, thirdAction, actionWithoutOrder]); + }); +}); diff --git a/packages/kbn-cell-actions/src/context/cell_actions_context.tsx b/packages/kbn-cell-actions/src/context/cell_actions_context.tsx new file mode 100644 index 0000000000000..af47a2fbe0ff7 --- /dev/null +++ b/packages/kbn-cell-actions/src/context/cell_actions_context.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { orderBy } from 'lodash/fp'; +import React, { createContext, FC, useCallback, useContext } from 'react'; +import type { CellAction, CellActionsProviderProps, GetActions } from '../types'; + +const CellActionsContext = createContext<{ getActions: GetActions } | null>(null); + +export const CellActionsProvider: FC = ({ + children, + getTriggerCompatibleActions, +}) => { + const getActions = useCallback( + (context) => + getTriggerCompatibleActions(context.trigger.id, context).then((actions) => + orderBy(['order', 'id'], ['asc', 'asc'], actions) + ) as Promise, + [getTriggerCompatibleActions] + ); + + return ( + {children} + ); +}; + +export const useCellActionsContext = () => { + const context = useContext(CellActionsContext); + if (!context) { + throw new Error( + 'No CellActionsContext found. Please wrap the application with CellActionsProvider' + ); + } + return context; +}; diff --git a/packages/kbn-cell-actions/src/context/index.ts b/packages/kbn-cell-actions/src/context/index.ts new file mode 100644 index 0000000000000..e5fff63ef7659 --- /dev/null +++ b/packages/kbn-cell-actions/src/context/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CellActionsProvider } from './cell_actions_context'; diff --git a/packages/kbn-cell-actions/src/hooks/actions.test.ts b/packages/kbn-cell-actions/src/hooks/actions.test.ts new file mode 100644 index 0000000000000..a7ca564570e2c --- /dev/null +++ b/packages/kbn-cell-actions/src/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/packages/kbn-cell-actions/src/hooks/actions.ts b/packages/kbn-cell-actions/src/hooks/actions.ts new file mode 100644 index 0000000000000..1eccdb737e483 --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/actions.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 { useMemo } from 'react'; +import type { PartitionedActions, CellAction } from '../types'; + +export const partitionActions = (actions: CellAction[], visibleCellActions: number) => { + if (visibleCellActions <= 1) return { extraActions: actions, visibleActions: [] }; + if (actions.length <= visibleCellActions) return { extraActions: [], visibleActions: actions }; + + return { + visibleActions: actions.slice(0, visibleCellActions - 1), + extraActions: actions.slice(visibleCellActions - 1, actions.length), + }; +}; + +export const usePartitionActions = ( + allActions: CellAction[], + visibleCellActions: number +): PartitionedActions => { + return useMemo(() => { + return partitionActions(allActions ?? [], visibleCellActions); + }, [allActions, visibleCellActions]); +}; diff --git a/packages/kbn-cell-actions/src/hooks/index.ts b/packages/kbn-cell-actions/src/hooks/index.ts new file mode 100644 index 0000000000000..94086c432065e --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { + useDataGridColumnsCellActions, + type UseDataGridColumnsCellActionsProps, +} from './use_data_grid_column_cell_actions'; diff --git a/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx new file mode 100644 index 0000000000000..db6a02b918ca1 --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { JSXElementConstructor } from 'react'; +import { + EuiButtonEmpty, + EuiDataGridColumnCellActionProps, + type EuiDataGridColumnCellAction, +} from '@elastic/eui'; +import { render } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { makeAction } from '../mocks/helpers'; +import { + useDataGridColumnsCellActions, + UseDataGridColumnsCellActionsProps, +} from './use_data_grid_column_cell_actions'; + +const action1 = makeAction('action-1', 'icon1', 1); +action1.execute = jest.fn(); +const action2 = makeAction('action-2', 'icon2', 2); +action2.execute = jest.fn(); +const actions = [action1, action2]; +const mockGetActions = jest.fn(async () => actions); + +jest.mock('../context/cell_actions_context', () => ({ + useCellActionsContext: () => ({ getActions: mockGetActions }), +})); + +const field1 = { name: 'column1', values: ['0.0', '0.1', '0.2', '0.3'], type: 'text' }; +const field2 = { name: 'column2', values: ['1.0', '1.1', '1.2', '1.3'], type: 'keyword' }; +const columns = [{ id: field1.name }, { id: field2.name }]; + +const useDataGridColumnsCellActionsProps: UseDataGridColumnsCellActionsProps = { + fields: [field1, field2], + triggerId: 'testTriggerId', + metadata: { some: 'value' }, +}; + +const renderCellAction = ( + columnCellAction: EuiDataGridColumnCellAction, + props: Partial = {} +) => { + const CellAction = columnCellAction as JSXElementConstructor; + return render( + + ); +}; + +describe('useDataGridColumnsCellActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return array with actions for each columns', async () => { + const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + expect(result.current).toHaveLength(columns.length); + expect(result.current[0]).toHaveLength(1); // loader + + await waitForNextUpdate(); + + expect(result.current).toHaveLength(columns.length); + expect(result.current[0]).toHaveLength(actions.length); + }); + + it('should render cell actions loading state', async () => { + const { result } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + + await act(async () => { + const cellAction = renderCellAction(result.current[0][0]); + expect(cellAction.getByTestId('dataGridColumnCellAction-loading')).toBeInTheDocument(); + }); + }); + + it('should render the cell actions', async () => { + const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + + await waitForNextUpdate(); + + const cellAction1 = renderCellAction(result.current[0][0]); + + expect(cellAction1.getByTestId(`dataGridColumnCellAction-${action1.id}`)).toBeInTheDocument(); + expect(cellAction1.getByText(action1.getDisplayName())).toBeInTheDocument(); + + const cellAction2 = renderCellAction(result.current[0][1]); + + expect(cellAction2.getByTestId(`dataGridColumnCellAction-${action2.id}`)).toBeInTheDocument(); + expect(cellAction2.getByText(action2.getDisplayName())).toBeInTheDocument(); + }); + + it('should execute the action on click', async () => { + const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + await waitForNextUpdate(); + + const cellAction = renderCellAction(result.current[0][0]); + + cellAction.getByTestId(`dataGridColumnCellAction-${action1.id}`).click(); + + expect(action1.execute).toHaveBeenCalled(); + }); + + it('should execute the action with correct context', async () => { + const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + await waitForNextUpdate(); + + const cellAction1 = renderCellAction(result.current[0][0], { rowIndex: 1 }); + + cellAction1.getByTestId(`dataGridColumnCellAction-${action1.id}`).click(); + + expect(action1.execute).toHaveBeenCalledWith( + expect.objectContaining({ + field: { name: field1.name, type: field1.type, value: field1.values[1] }, + trigger: { id: useDataGridColumnsCellActionsProps.triggerId }, + }) + ); + + const cellAction2 = renderCellAction(result.current[1][1], { rowIndex: 2 }); + + cellAction2.getByTestId(`dataGridColumnCellAction-${action2.id}`).click(); + + expect(action2.execute).toHaveBeenCalledWith( + expect.objectContaining({ + field: { name: field2.name, type: field2.type, value: field2.values[2] }, + trigger: { id: useDataGridColumnsCellActionsProps.triggerId }, + }) + ); + }); + + it('should execute the action with correct page value', async () => { + const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, { + initialProps: useDataGridColumnsCellActionsProps, + }); + await waitForNextUpdate(); + + const cellAction = renderCellAction(result.current[0][0], { rowIndex: 25 }); + + cellAction.getByTestId(`dataGridColumnCellAction-${action1.id}`).click(); + + expect(action1.execute).toHaveBeenCalledWith( + expect.objectContaining({ + field: { name: field1.name, type: field1.type, value: field1.values[1] }, + }) + ); + }); +}); diff --git a/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.tsx b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.tsx new file mode 100644 index 0000000000000..6212ad2c30776 --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useMemo, useRef } from 'react'; +import { EuiLoadingSpinner, type EuiDataGridColumnCellAction } from '@elastic/eui'; +import type { + CellAction, + CellActionExecutionContext, + CellActionField, + CellActionsProps, +} from '../types'; +import { useBulkLoadActions } from './use_load_actions'; + +interface BulkField extends Pick { + /** + * Array containing all the values of the field in the visible page, indexed by rowIndex + */ + values: Array; +} + +export interface UseDataGridColumnsCellActionsProps + extends Pick { + fields: BulkField[]; +} +export const useDataGridColumnsCellActions = ({ + fields, + triggerId, + metadata, +}: UseDataGridColumnsCellActionsProps): EuiDataGridColumnCellAction[][] => { + const bulkContexts: CellActionExecutionContext[] = useMemo( + () => + fields.map(({ values, ...field }) => ({ + field, // we are getting the actions for the whole column field, so the compatibility check will be done without the value + trigger: { id: triggerId }, + metadata, + })), + [fields, triggerId, metadata] + ); + + const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts); + + const columnsCellActions = useMemo(() => { + if (loading) { + return fields.map(() => [ + () => , + ]); + } + if (!columnsActions) { + return []; + } + return columnsActions.map((actions, columnIndex) => + actions.map((action) => + createColumnCellAction({ action, metadata, triggerId, field: fields[columnIndex] }) + ) + ); + }, [columnsActions, fields, loading, metadata, triggerId]); + + return columnsCellActions; +}; + +interface CreateColumnCellActionParams extends Pick { + field: BulkField; + action: CellAction; +} +const createColumnCellAction = ({ + field, + action, + metadata, + triggerId, +}: CreateColumnCellActionParams): EuiDataGridColumnCellAction => + function ColumnCellAction({ Component, rowIndex }) { + const nodeRef = useRef(null); + const extraContentNodeRef = useRef(null); + + const { name, type, values } = field; + // rowIndex refers to all pages, we need to use the row index relative to the page to get the value + const value = values[rowIndex % values.length]; + + const actionContext: CellActionExecutionContext = { + field: { name, type, value }, + trigger: { id: triggerId }, + extraContentNodeRef, + nodeRef, + metadata, + }; + + return ( + nodeRef} + aria-label={action.getDisplayName(actionContext)} + title={action.getDisplayName(actionContext)} + data-test-subj={`dataGridColumnCellAction-${action.id}`} + iconType={action.getIconType(actionContext)!} + onClick={() => { + action.execute(actionContext); + }} + > + {action.getDisplayName(actionContext)} +
extraContentNodeRef} /> + + ); + }; diff --git a/packages/kbn-cell-actions/src/hooks/use_load_actions.test.ts b/packages/kbn-cell-actions/src/hooks/use_load_actions.test.ts new file mode 100644 index 0000000000000..74cb5091a5efb --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/use_load_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 { act, renderHook } from '@testing-library/react-hooks'; +import { makeAction, makeActionContext } from '../mocks/helpers'; +import { useBulkLoadActions, useLoadActions, useLoadActionsFn } from './use_load_actions'; + +const action = makeAction('action-1', 'icon', 1); +const mockGetActions = jest.fn(async () => [action]); +jest.mock('../context/cell_actions_context', () => ({ + useCellActionsContext: () => ({ getActions: mockGetActions }), +})); + +describe('useLoadActions', () => { + const actionContext = makeActionContext(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('loads actions when useLoadActions called', async () => { + const { result, waitForNextUpdate } = renderHook(useLoadActions, { + initialProps: actionContext, + }); + + expect(result.current.value).toBeUndefined(); + expect(result.current.loading).toEqual(true); + expect(mockGetActions).toHaveBeenCalledTimes(1); + expect(mockGetActions).toHaveBeenCalledWith(actionContext); + + await waitForNextUpdate(); + + expect(result.current.value).toEqual([action]); + expect(result.current.loading).toEqual(false); + }); + + it('loads actions when useLoadActionsFn function is called', async () => { + const { result, waitForNextUpdate } = renderHook(useLoadActionsFn); + const [{ value: valueBeforeCall, loading: loadingBeforeCall }, loadActions] = result.current; + + expect(valueBeforeCall).toBeUndefined(); + expect(loadingBeforeCall).toEqual(false); + expect(mockGetActions).not.toHaveBeenCalled(); + + act(() => { + loadActions(actionContext); + }); + + const [{ value: valueAfterCall, loading: loadingAfterCall }] = result.current; + expect(valueAfterCall).toBeUndefined(); + expect(loadingAfterCall).toEqual(true); + expect(mockGetActions).toHaveBeenCalledTimes(1); + expect(mockGetActions).toHaveBeenCalledWith(actionContext); + + await waitForNextUpdate(); + + const [{ value: valueAfterUpdate, loading: loadingAfterUpdate }] = result.current; + expect(valueAfterUpdate).toEqual([action]); + expect(loadingAfterUpdate).toEqual(false); + }); + + it('loads bulk actions array when useBulkLoadActions is called', async () => { + const actionContext2 = makeActionContext({ trigger: { id: 'triggerId2' } }); + const actionContexts = [actionContext, actionContext2]; + const { result, waitForNextUpdate } = renderHook(useBulkLoadActions, { + initialProps: actionContexts, + }); + + expect(result.current.value).toBeUndefined(); + expect(result.current.loading).toEqual(true); + expect(mockGetActions).toHaveBeenCalledTimes(2); + expect(mockGetActions).toHaveBeenCalledWith(actionContext); + expect(mockGetActions).toHaveBeenCalledWith(actionContext2); + + await waitForNextUpdate(); + + expect(result.current.value).toEqual([[action], [action]]); + expect(result.current.loading).toEqual(false); + }); +}); diff --git a/packages/kbn-cell-actions/src/hooks/use_load_actions.ts b/packages/kbn-cell-actions/src/hooks/use_load_actions.ts new file mode 100644 index 0000000000000..85d8f64042f93 --- /dev/null +++ b/packages/kbn-cell-actions/src/hooks/use_load_actions.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import useAsync from 'react-use/lib/useAsync'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { useCellActionsContext } from '../context/cell_actions_context'; +import { CellActionExecutionContext } from '../types'; + +/** + * Performs the getActions async call and returns its value + */ +export const useLoadActions = (context: CellActionExecutionContext) => { + const { getActions } = useCellActionsContext(); + return useAsync(() => getActions(context), []); +}; + +/** + * Returns a function to perform the getActions async call + */ +export const useLoadActionsFn = () => { + const { getActions } = useCellActionsContext(); + return useAsyncFn(getActions, []); +}; + +/** + * Groups getActions calls for an array of contexts in one async bulk operation + */ +export const useBulkLoadActions = (contexts: CellActionExecutionContext[]) => { + const { getActions } = useCellActionsContext(); + return useAsync(() => Promise.all(contexts.map((context) => getActions(context))), []); +}; diff --git a/packages/kbn-cell-actions/src/index.ts b/packages/kbn-cell-actions/src/index.ts new file mode 100644 index 0000000000000..69c4b0fbc6e13 --- /dev/null +++ b/packages/kbn-cell-actions/src/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { CellActions } from './components'; +export { CellActionsProvider } from './context'; +export { useDataGridColumnsCellActions, type UseDataGridColumnsCellActionsProps } from './hooks'; +export { CellActionsMode } from './types'; +export type { CellAction, CellActionExecutionContext } from './types'; diff --git a/packages/kbn-cell-actions/src/mocks/helpers.ts b/packages/kbn-cell-actions/src/mocks/helpers.ts new file mode 100644 index 0000000000000..21f1047f384ad --- /dev/null +++ b/packages/kbn-cell-actions/src/mocks/helpers.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 { CellActionExecutionContext } from '../types'; + +export const makeAction = (actionsName: string, icon: string = 'icon', order?: number) => ({ + id: actionsName, + type: actionsName, + order, + getIconType: () => icon, + getDisplayName: () => actionsName, + getDisplayNameTooltip: () => actionsName, + isCompatible: () => Promise.resolve(true), + execute: () => { + alert(actionsName); + return Promise.resolve(); + }, +}); + +export const makeActionContext = ( + override: Partial = {} +): CellActionExecutionContext => ({ + trigger: { id: 'triggerId' }, + field: { + name: 'fieldName', + type: 'keyword', + }, + ...override, +}); diff --git a/packages/kbn-cell-actions/src/types.ts b/packages/kbn-cell-actions/src/types.ts new file mode 100644 index 0000000000000..592a412e7fb68 --- /dev/null +++ b/packages/kbn-cell-actions/src/types.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { + Action, + ActionExecutionContext, + UiActionsService, +} from '@kbn/ui-actions-plugin/public'; + +export type CellAction = Action; + +export interface CellActionsProviderProps { + /** + * Please assign `uiActions.getTriggerCompatibleActions` function. + * This function should return a list of actions for a triggerId that are compatible with the provided context. + */ + getTriggerCompatibleActions: UiActionsService['getTriggerCompatibleActions']; +} + +export type GetActions = (context: CellActionExecutionContext) => Promise; + +export interface CellActionField { + /** + * Field name. + * Example: 'host.name' + */ + name: string; + /** + * Field type. + * Example: 'keyword' + */ + type: string; + /** + * Field value. + * Example: 'My-Laptop' + */ + value?: string | string[] | null; +} + +export interface PartitionedActions { + extraActions: CellAction[]; + visibleActions: CellAction[]; +} + +export interface CellActionExecutionContext extends ActionExecutionContext { + field: CellActionField; + /** + * Ref to a DOM node where the action can add custom HTML. + */ + extraContentNodeRef?: React.MutableRefObject; + + /** + * Ref to the node where the cell action are rendered. + */ + nodeRef?: React.MutableRefObject; + + /** + * Extra configurations for actions. + */ + metadata?: Record; +} + +export enum CellActionsMode { + HOVER = 'hover', + INLINE = 'inline', +} + +export interface CellActionsProps { + /** + * Common set of properties used by most actions. + */ + field: CellActionField; + /** + * The trigger in which the actions are registered. + */ + triggerId: string; + /** + * UI configuration. Possible options are `HOVER` and `INLINE`. + * + * `HOVER` shows the actions when the children component is hovered. + * + * `INLINE` always shows the actions. + */ + mode: CellActionsMode; + + /** + * It displays a tooltip for every action button when `true`. + */ + showActionTooltips?: boolean; + /** + * It shows 'more actions' button when the number of actions is bigger than this parameter. + */ + visibleCellActions?: number; + /** + * Custom set of properties used by some actions. + * An action might require a specific set of metadata properties to render. + * This data is sent directly to actions. + */ + metadata?: Record; +} diff --git a/packages/kbn-cell-actions/tsconfig.json b/packages/kbn-cell-actions/tsconfig.json new file mode 100644 index 0000000000000..63c76dfbfaa45 --- /dev/null +++ b/packages/kbn-cell-actions/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node", + "react", + "@emotion/react/types/css-prop", + "@testing-library/jest-dom", + "@testing-library/react" + ] + }, + "include": ["**/*.ts", "**/*.tsx"], + "kbn_references": [ + "@kbn/ui-theme", + "@kbn/i18n", + "@kbn/ui-actions-plugin", + ], + "exclude": ["target/**/*"] +} diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 99918241190df..9e2ba93dc4229 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -11,6 +11,7 @@ export const storybookAliases = { apm: 'x-pack/plugins/apm/.storybook', canvas: 'x-pack/plugins/canvas/storybook', cases: 'packages/kbn-cases-components/.storybook', + cell_actions: 'packages/kbn-cell-actions/.storybook', ci_composite: '.ci/.storybook', cloud_chat: 'x-pack/plugins/cloud_integrations/cloud_chat/.storybook', coloring: 'packages/kbn-coloring/.storybook', diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index 1c6faa93d01d1..17460c4e08012 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -6,6 +6,7 @@ cd "$KIBANA_DIR" yarn storybook --site apm yarn storybook --site canvas +yarn storybook --site cell_actions yarn storybook --site ci_composite yarn storybook --site content_management yarn storybook --site custom_integrations diff --git a/tsconfig.base.json b/tsconfig.base.json index 8b2aed59945cf..ce8e855125478 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -92,6 +92,8 @@ "@kbn/cases-fixture-plugin/*": ["x-pack/test/functional_with_es_ssl/plugins/cases/*"], "@kbn/cases-plugin": ["x-pack/plugins/cases"], "@kbn/cases-plugin/*": ["x-pack/plugins/cases/*"], + "@kbn/cell-actions": ["packages/kbn-cell-actions"], + "@kbn/cell-actions/*": ["packages/kbn-cell-actions/*"], "@kbn/chart-expressions-common": ["src/plugins/chart_expressions/common"], "@kbn/chart-expressions-common/*": ["src/plugins/chart_expressions/common/*"], "@kbn/chart-icons": ["packages/kbn-chart-icons"], diff --git a/yarn.lock b/yarn.lock index ecda18a72e2ba..f2e8684ba222f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2825,6 +2825,10 @@ version "0.0.0" uid "" +"@kbn/cell-actions@link:packages/kbn-cell-actions": + version "0.0.0" + uid "" + "@kbn/chart-expressions-common@link:src/plugins/chart_expressions/common": version "0.0.0" uid ""