From 1e15a7f4aaa2c73c360d3c2107ca8a8e86ece78d Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 19 Jan 2023 11:52:10 +0100 Subject: [PATCH] [Security Solution] [CellActions] Move to a package (#149057) Epic: https://github.com/elastic/kibana/issues/144943 ## Summary Moving the existing CellActions implementation to a new home. The `kbn-cell-actions` package contains components and hooks that are going to be used by solutions to show data cell actions with a consistent UI across them. Security Solution is going to start using it by migrating all "hover-actions" to the unified implementation, but the usage is not restricted to it. Any plugin can register and attach its own actions to a trigger via uiActions, and use this package to render the CellActions components in a consistent way. The initial implementation was placed in the uiActions plugin itself due to a types constraints (https://github.com/elastic/kibana/tree/main/src/plugins/ui_actions/public/cell_actions), the constraint has been solved so we are creating the package for it as planned. This PR only moves that implementation to the new package, with small directory changes. The exported components are not being used anywhere currently, so the implementation may change during the migration phase. ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 1 + .i18nrc.json | 1 + package.json | 1 + packages/kbn-cell-actions/.storybook/main.js | 9 + packages/kbn-cell-actions/README.md | 15 ++ packages/kbn-cell-actions/index.ts | 9 + packages/kbn-cell-actions/jest.config.js | 13 ++ packages/kbn-cell-actions/kibana.jsonc | 5 + packages/kbn-cell-actions/package.json | 7 + .../src/__stories__/cell_actions.stories.tsx | 78 +++++++ .../src/components/cell_action_item.test.tsx | 34 ++++ .../src/components/cell_action_item.tsx | 44 ++++ .../src/components/cell_actions.test.tsx | 75 +++++++ .../src/components/cell_actions.tsx | 64 ++++++ .../components/extra_actions_button.test.tsx | 32 +++ .../src/components/extra_actions_button.tsx | 35 ++++ .../components/extra_actions_popover.test.tsx | 90 +++++++++ .../src/components/extra_actions_popover.tsx | 132 ++++++++++++ .../components/hover_actions_popover.test.tsx | 191 ++++++++++++++++++ .../src/components/hover_actions_popover.tsx | 168 +++++++++++++++ .../kbn-cell-actions/src/components/index.tsx | 9 + .../src/components/inline_actions.test.tsx | 63 ++++++ .../src/components/inline_actions.tsx | 62 ++++++ .../src/components/translations.ts | 26 +++ .../src/context/cell_actions_context.test.tsx | 84 ++++++++ .../src/context/cell_actions_context.tsx | 40 ++++ .../kbn-cell-actions/src/context/index.ts | 9 + .../src/hooks/actions.test.ts | 85 ++++++++ .../kbn-cell-actions/src/hooks/actions.ts | 29 +++ packages/kbn-cell-actions/src/hooks/index.ts | 12 ++ ...use_data_grid_column_cell_actions.test.tsx | 166 +++++++++++++++ .../use_data_grid_column_cell_actions.tsx | 107 ++++++++++ .../src/hooks/use_load_actions.test.ts | 85 ++++++++ .../src/hooks/use_load_actions.ts | 36 ++++ packages/kbn-cell-actions/src/index.ts | 13 ++ .../kbn-cell-actions/src/mocks/helpers.ts | 34 ++++ packages/kbn-cell-actions/src/types.ts | 105 ++++++++++ packages/kbn-cell-actions/tsconfig.json | 21 ++ src/dev/storybook/aliases.ts | 1 + test/scripts/jenkins_storybook.sh | 1 + tsconfig.base.json | 2 + yarn.lock | 4 + 42 files changed, 1998 insertions(+) create mode 100644 packages/kbn-cell-actions/.storybook/main.js create mode 100644 packages/kbn-cell-actions/README.md create mode 100644 packages/kbn-cell-actions/index.ts create mode 100644 packages/kbn-cell-actions/jest.config.js create mode 100644 packages/kbn-cell-actions/kibana.jsonc create mode 100644 packages/kbn-cell-actions/package.json create mode 100644 packages/kbn-cell-actions/src/__stories__/cell_actions.stories.tsx create mode 100644 packages/kbn-cell-actions/src/components/cell_action_item.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/cell_action_item.tsx create mode 100644 packages/kbn-cell-actions/src/components/cell_actions.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/cell_actions.tsx create mode 100644 packages/kbn-cell-actions/src/components/extra_actions_button.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/extra_actions_button.tsx create mode 100644 packages/kbn-cell-actions/src/components/extra_actions_popover.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/extra_actions_popover.tsx create mode 100644 packages/kbn-cell-actions/src/components/hover_actions_popover.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/hover_actions_popover.tsx create mode 100644 packages/kbn-cell-actions/src/components/index.tsx create mode 100644 packages/kbn-cell-actions/src/components/inline_actions.test.tsx create mode 100644 packages/kbn-cell-actions/src/components/inline_actions.tsx create mode 100644 packages/kbn-cell-actions/src/components/translations.ts create mode 100644 packages/kbn-cell-actions/src/context/cell_actions_context.test.tsx create mode 100644 packages/kbn-cell-actions/src/context/cell_actions_context.tsx create mode 100644 packages/kbn-cell-actions/src/context/index.ts create mode 100644 packages/kbn-cell-actions/src/hooks/actions.test.ts create mode 100644 packages/kbn-cell-actions/src/hooks/actions.ts create mode 100644 packages/kbn-cell-actions/src/hooks/index.ts create mode 100644 packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.test.tsx create mode 100644 packages/kbn-cell-actions/src/hooks/use_data_grid_column_cell_actions.tsx create mode 100644 packages/kbn-cell-actions/src/hooks/use_load_actions.test.ts create mode 100644 packages/kbn-cell-actions/src/hooks/use_load_actions.ts create mode 100644 packages/kbn-cell-actions/src/index.ts create mode 100644 packages/kbn-cell-actions/src/mocks/helpers.ts create mode 100644 packages/kbn-cell-actions/src/types.ts create mode 100644 packages/kbn-cell-actions/tsconfig.json 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 ""