From 034625a70b689f636a4fe976865a162578426695 Mon Sep 17 00:00:00 2001 From: Davis McPhee Date: Tue, 10 Sep 2024 21:24:32 -0300 Subject: [PATCH] [Discover] Cell actions extension (#190754) ## Summary This PR adds a new cell actions extension to Discover using the [`kbn-cell-actions`](packages/kbn-cell-actions) framework which Unified Data Table already supports, allowing profiles to register additional cell actions within the data grid: cell_actions The extension point supports the following: - Cell actions can be registered at the root or data source level. - Supports an `isCompatible` method, allowing cell actions to be shown for all cells in a column or conditionally based on the column field, etc. - Cell actions have access to a `context` object including the current `field`, `value`, `dataSource`, `dataView`, `query`, `filters`, and `timeRange`. **Note that currently cell actions do not have access to the entire record, only the current cell value. We can support this as a followup if needed, but it will require an enhancement to `kbn-cell-actions`.** ## Testing - Add `discover.experimental.enabledProfiles: ['example-root-profile', 'example-data-source-profile', 'example-document-profile']` to `kibana.dev.yml` and start Kibana. - Ingest the Discover context awareness example data using the following command: `node scripts/es_archiver --kibana-url=http://elastic:changeme@localhost:5601 --es-url=http://elastic:changeme@localhost:9200 load test/functional/fixtures/es_archiver/discover/context_awareness`. - Navigate to Discover and create a `my-example-logs` data view or target the index in an ES|QL query. - Confirm that the example cell actions appear in expanded cell popover menus and are functional. Resolves #186576. ### Checklist - [ ] 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) - [ ] [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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .../src/components/data_table.tsx | 35 ++- .../components/data_table_columns.test.tsx | 79 ++++++ .../src/components/data_table_columns.tsx | 11 +- .../common/data_sources/utils.test.ts | 35 +++ .../discover/common/data_sources/utils.ts | 23 +- .../__mocks__/data_view_with_timefield.ts | 12 +- .../discover/public/__mocks__/services.ts | 1 + .../application/context/context_app.test.tsx | 12 +- .../context/context_app_content.test.tsx | 7 +- .../context/context_app_content.tsx | 29 +- .../application/context/services/anchor.ts | 4 +- .../components/layout/discover_documents.tsx | 28 +- .../context_awareness/__mocks__/index.tsx | 23 ++ .../public/context_awareness/hooks/index.ts | 1 + .../use_additional_cell_actions.test.tsx | 251 ++++++++++++++++++ .../hooks/use_additional_cell_actions.ts | 107 ++++++++ .../public/context_awareness/index.ts | 2 +- .../example_data_source_profile/profile.tsx | 21 ++ .../public/context_awareness/types.ts | 37 +++ .../search_embeddable_grid_component.tsx | 43 ++- .../get_search_embeddable_factory.tsx | 2 +- .../public/embeddable/initialize_fetch.ts | 8 +- src/plugins/discover/public/plugin.tsx | 12 +- .../_get_additional_cell_actions.ts | 172 ++++++++++++ .../apps/discover/context_awareness/index.ts | 1 + test/functional/services/data_grid.ts | 17 ++ .../_get_additional_cell_actions.ts | 180 +++++++++++++ .../discover/context_awareness/index.ts | 1 + 28 files changed, 1105 insertions(+), 49 deletions(-) create mode 100644 src/plugins/discover/common/data_sources/utils.test.ts create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.test.tsx create mode 100644 src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.ts create mode 100644 test/functional/apps/discover/context_awareness/extensions/_get_additional_cell_actions.ts create mode 100644 x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_additional_cell_actions.ts diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index 7c83f9bd6ccf7..2b582b965892a 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -271,10 +271,6 @@ export interface UnifiedDataTableProps { * Callback to execute on edit runtime field */ onFieldEdited?: () => void; - /** - * Optional triggerId to retrieve the column cell actions that will override the default ones - */ - cellActionsTriggerId?: string; /** * Service dependencies */ @@ -353,6 +349,20 @@ export interface UnifiedDataTableProps { * @param gridProps */ renderCustomToolbar?: UnifiedDataTableRenderCustomToolbar; + /** + * Optional triggerId to retrieve the column cell actions that will override the default ones + */ + cellActionsTriggerId?: string; + /** + * 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. + */ + cellActionsMetadata?: Record; + /** + * Controls whether the cell actions should replace the default cell actions or be appended to them + */ + cellActionsHandling?: 'replace' | 'append'; /** * An optional value for a custom number of the visible cell actions in the table. By default is up to 3. **/ @@ -389,12 +399,6 @@ export interface UnifiedDataTableProps { * Set to true to allow users to compare selected documents */ enableComparisonMode?: boolean; - /** - * 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. - */ - cellActionsMetadata?: Record; /** * Optional extra props passed to the renderCellValue function/component. */ @@ -441,6 +445,9 @@ export const UnifiedDataTable = ({ isSortEnabled = true, isPaginationEnabled = true, cellActionsTriggerId, + cellActionsMetadata, + cellActionsHandling = 'replace', + visibleCellActions, className, rowHeightState, onUpdateRowHeight, @@ -466,14 +473,12 @@ export const UnifiedDataTable = ({ maxDocFieldsDisplayed = 50, externalAdditionalControls, rowsPerPageOptions, - visibleCellActions, externalCustomRenderers, additionalFieldGroups, consumer = 'discover', componentsTourSteps, gridStyleOverride, rowLineHeightOverride, - cellActionsMetadata, customGridColumnsConfiguration, enableComparisonMode, cellContext, @@ -752,7 +757,7 @@ export const UnifiedDataTable = ({ const cellActionsFields = useMemo( () => - cellActionsTriggerId && !isPlainRecord + cellActionsTriggerId ? visibleColumns.map( (columnName) => dataView.getFieldByName(columnName)?.toSpec() ?? { @@ -763,7 +768,7 @@ export const UnifiedDataTable = ({ } ) : undefined, - [cellActionsTriggerId, isPlainRecord, visibleColumns, dataView] + [cellActionsTriggerId, visibleColumns, dataView] ); const allCellActionsMetadata = useMemo( () => ({ dataViewId: dataView.id, ...(cellActionsMetadata ?? {}) }), @@ -806,6 +811,7 @@ export const UnifiedDataTable = ({ getEuiGridColumns({ columns: visibleColumns, columnsCellActions, + cellActionsHandling, rowsCount: displayedRows.length, settings, dataView, @@ -829,6 +835,7 @@ export const UnifiedDataTable = ({ onResize, }), [ + cellActionsHandling, columnsMeta, columnsCellActions, customGridColumnsConfiguration, diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx index a1736d403d8f6..23fcd85de1020 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.test.tsx @@ -52,6 +52,7 @@ describe('Data table columns', function () { servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(actual).toMatchSnapshot(); }); @@ -75,6 +76,7 @@ describe('Data table columns', function () { servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(actual).toMatchSnapshot(); }); @@ -103,9 +105,79 @@ describe('Data table columns', function () { timestamp: { type: 'date', esType: 'dateTime' }, }, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(actual).toMatchSnapshot(); }); + + describe('cell actions', () => { + it('should replace cell actions', async () => { + const cellAction = jest.fn(); + const actual = getEuiGridColumns({ + columns: columnsWithTimeCol, + settings: {}, + dataView: dataViewWithTimefieldMock, + defaultColumns: false, + isSortEnabled: true, + isPlainRecord: true, + valueToStringConverter: dataTableContextMock.valueToStringConverter, + rowsCount: 100, + headerRowHeightLines: 5, + services: { + uiSettings: servicesMock.uiSettings, + toastNotifications: servicesMock.toastNotifications, + }, + hasEditDataViewPermission: () => + servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), + onFilter: () => {}, + columnsMeta: { + extension: { type: 'string' }, + message: { type: 'string', esType: 'keyword' }, + timestamp: { type: 'date', esType: 'dateTime' }, + }, + onResize: () => {}, + columnsCellActions: [[cellAction]], + cellActionsHandling: 'replace', + }); + expect(actual[0].cellActions).toEqual([cellAction]); + }); + + it('should append cell actions', async () => { + const cellAction = jest.fn(); + const actual = getEuiGridColumns({ + columns: columnsWithTimeCol, + settings: {}, + dataView: dataViewWithTimefieldMock, + defaultColumns: false, + isSortEnabled: true, + isPlainRecord: true, + valueToStringConverter: dataTableContextMock.valueToStringConverter, + rowsCount: 100, + headerRowHeightLines: 5, + services: { + uiSettings: servicesMock.uiSettings, + toastNotifications: servicesMock.toastNotifications, + }, + hasEditDataViewPermission: () => + servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), + onFilter: () => {}, + columnsMeta: { + extension: { type: 'string' }, + message: { type: 'string', esType: 'keyword' }, + timestamp: { type: 'date', esType: 'dateTime' }, + }, + onResize: () => {}, + columnsCellActions: [[cellAction]], + cellActionsHandling: 'append', + }); + expect(actual[0].cellActions).toEqual([ + expect.any(Function), + expect.any(Function), + expect.any(Function), + cellAction, + ]); + }); + }); }); describe('getVisibleColumns', () => { @@ -302,6 +374,7 @@ describe('Data table columns', function () { servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(actual).toMatchSnapshot(); }); @@ -330,6 +403,7 @@ describe('Data table columns', function () { servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onFilter: () => {}, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(actual).toMatchSnapshot(); }); @@ -363,6 +437,7 @@ describe('Data table columns', function () { extension: { type: 'string' }, }, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(gridColumns[1].schema).toBe('string'); }); @@ -394,6 +469,7 @@ describe('Data table columns', function () { var_test: { type: 'number' }, }, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(gridColumns[1].schema).toBe('numeric'); }); @@ -421,6 +497,7 @@ describe('Data table columns', function () { message: { type: 'string', esType: 'keyword' }, }, onResize: () => {}, + cellActionsHandling: 'replace', }); const extensionGridColumn = gridColumns[0]; @@ -452,6 +529,7 @@ describe('Data table columns', function () { message: { type: 'string', esType: 'keyword' }, }, onResize: () => {}, + cellActionsHandling: 'replace', }); expect(customizedGridColumns).toMatchSnapshot(); @@ -495,6 +573,7 @@ describe('Data table columns', function () { hasEditDataViewPermission: () => servicesMock.dataViewFieldEditor.userPermissions.editIndexPattern(), onResize: () => {}, + cellActionsHandling: 'replace', }); const columnDisplayNames = customizedGridColumns.map((column) => column.displayAsText); expect(columnDisplayNames.includes('test_column_one')).toBeTruthy(); diff --git a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx index ff7daf13de3ec..10f55431faa71 100644 --- a/packages/kbn-unified-data-table/src/components/data_table_columns.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table_columns.tsx @@ -102,6 +102,7 @@ function buildEuiGridColumn({ onFilter, editField, columnCellActions, + cellActionsHandling, visibleCellActions, columnsMeta, showColumnTokens, @@ -124,6 +125,7 @@ function buildEuiGridColumn({ onFilter?: DocViewFilterFn; editField?: (fieldName: string) => void; columnCellActions?: EuiDataGridColumnCellAction[]; + cellActionsHandling: 'replace' | 'append'; visibleCellActions?: number; columnsMeta?: DataTableColumnsMeta; showColumnTokens?: boolean; @@ -176,12 +178,16 @@ function buildEuiGridColumn({ let cellActions: EuiDataGridColumnCellAction[]; - if (columnCellActions?.length) { + if (columnCellActions?.length && cellActionsHandling === 'replace') { cellActions = columnCellActions; } else { cellActions = dataViewField ? buildCellActions(dataViewField, toastNotifications, valueToStringConverter, onFilter) : []; + + if (columnCellActions?.length && cellActionsHandling === 'append') { + cellActions.push(...columnCellActions); + } } const columnType = columnsMeta?.[columnName]?.type ?? dataViewField?.type; @@ -278,6 +284,7 @@ export const deserializeHeaderRowHeight = (headerRowHeightLines: number) => { export function getEuiGridColumns({ columns, columnsCellActions, + cellActionsHandling, rowsCount, settings, dataView, @@ -298,6 +305,7 @@ export function getEuiGridColumns({ }: { columns: string[]; columnsCellActions?: EuiDataGridColumnCellAction[][]; + cellActionsHandling: 'replace' | 'append'; rowsCount: number; settings: UnifiedDataTableSettings | undefined; dataView: DataView; @@ -328,6 +336,7 @@ export function getEuiGridColumns({ numberOfColumns, columnName: column, columnCellActions: columnsCellActions?.[columnIndex], + cellActionsHandling, columnWidth: getColWidth(column), dataView, defaultColumns, diff --git a/src/plugins/discover/common/data_sources/utils.test.ts b/src/plugins/discover/common/data_sources/utils.test.ts new file mode 100644 index 0000000000000..8cc858612d579 --- /dev/null +++ b/src/plugins/discover/common/data_sources/utils.test.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { DataView } from '@kbn/data-views-plugin/common'; +import { dataViewWithTimefieldMock } from '../../public/__mocks__/data_view_with_timefield'; +import { createDataSource, createDataViewDataSource, createEsqlDataSource } from './utils'; + +describe('createDataSource', () => { + it('should return ES|QL source when ES|QL query', () => { + const dataView = dataViewWithTimefieldMock; + const query = { esql: 'FROM *' }; + const result = createDataSource({ dataView, query }); + expect(result).toEqual(createEsqlDataSource()); + }); + + it('should return data view source when not ES|QL query and dataView id is defined', () => { + const dataView = dataViewWithTimefieldMock; + const query = { language: 'kql', query: 'test' }; + const result = createDataSource({ dataView, query }); + expect(result).toEqual(createDataViewDataSource({ dataViewId: dataView.id! })); + }); + + it('should return undefined when not ES|QL query and dataView id is not defined', () => { + const dataView = { ...dataViewWithTimefieldMock, id: undefined } as DataView; + const query = { language: 'kql', query: 'test' }; + const result = createDataSource({ dataView, query }); + expect(result).toEqual(undefined); + }); +}); diff --git a/src/plugins/discover/common/data_sources/utils.ts b/src/plugins/discover/common/data_sources/utils.ts index 1f6118e2ae1fd..70e9546f86943 100644 --- a/src/plugins/discover/common/data_sources/utils.ts +++ b/src/plugins/discover/common/data_sources/utils.ts @@ -7,7 +7,14 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DataSourceType, DataViewDataSource, DiscoverDataSource, EsqlDataSource } from './types'; +import { isOfAggregateQueryType, type AggregateQuery, type Query } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import { + DataSourceType, + type DataViewDataSource, + type DiscoverDataSource, + type EsqlDataSource, +} from './types'; export const createDataViewDataSource = ({ dataViewId, @@ -22,6 +29,20 @@ export const createEsqlDataSource = (): EsqlDataSource => ({ type: DataSourceType.Esql, }); +export const createDataSource = ({ + dataView, + query, +}: { + dataView: DataView | undefined; + query: Query | AggregateQuery | undefined; +}) => { + return isOfAggregateQueryType(query) + ? createEsqlDataSource() + : dataView?.id + ? createDataViewDataSource({ dataViewId: dataView.id }) + : undefined; +}; + export const isDataSourceType = ( dataSource: DiscoverDataSource | undefined, type: T diff --git a/src/plugins/discover/public/__mocks__/data_view_with_timefield.ts b/src/plugins/discover/public/__mocks__/data_view_with_timefield.ts index 0c9474d67e30c..1895068cfc640 100644 --- a/src/plugins/discover/public/__mocks__/data_view_with_timefield.ts +++ b/src/plugins/discover/public/__mocks__/data_view_with_timefield.ts @@ -7,7 +7,8 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { DataView } from '@kbn/data-views-plugin/public'; +import { fieldList } from '@kbn/data-views-plugin/common'; +import { FieldSpec } from '@kbn/data-views-plugin/public'; import { buildDataViewMock } from '@kbn/discover-utils/src/__mocks__'; const fields = [ @@ -16,6 +17,7 @@ const fields = [ type: 'string', scripted: false, filterable: true, + searchable: true, }, { name: 'timestamp', @@ -25,6 +27,7 @@ const fields = [ filterable: true, aggregatable: true, sortable: true, + searchable: true, }, { name: 'message', @@ -32,6 +35,7 @@ const fields = [ type: 'string', scripted: false, filterable: false, + searchable: true, }, { name: 'extension', @@ -40,6 +44,7 @@ const fields = [ scripted: false, filterable: true, aggregatable: true, + searchable: true, }, { name: 'bytes', @@ -48,6 +53,7 @@ const fields = [ scripted: false, filterable: true, aggregatable: true, + searchable: true, }, { name: 'scripted', @@ -56,10 +62,10 @@ const fields = [ scripted: true, filterable: false, }, -] as DataView['fields']; +]; export const dataViewWithTimefieldMock = buildDataViewMock({ name: 'index-pattern-with-timefield', - fields, + fields: fieldList(fields as unknown as FieldSpec[]), timeFieldName: 'timestamp', }); diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index ea84b45889231..3d78239558f3e 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -148,6 +148,7 @@ export function createDiscoverServicesMock(): DiscoverServices { corePluginMock.chrome.getActiveSolutionNavId$.mockReturnValue(new BehaviorSubject(null)); return { + application: corePluginMock.application, core: corePluginMock, charts: chartPluginMock.createSetupContract(), chrome: chromeServiceMock.createStartContract(), diff --git a/src/plugins/discover/public/application/context/context_app.test.tsx b/src/plugins/discover/public/application/context/context_app.test.tsx index b8b6025d5b0bc..9c77d1e40bbb2 100644 --- a/src/plugins/discover/public/application/context/context_app.test.tsx +++ b/src/plugins/discover/public/application/context/context_app.test.tsx @@ -22,7 +22,6 @@ import { uiSettingsMock } from '../../__mocks__/ui_settings'; import { themeServiceMock } from '@kbn/core/public/mocks'; import { LocalStorageMock } from '../../__mocks__/local_storage_mock'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import type { HistoryLocationState } from '../../build_services'; import { createSearchSessionMock } from '../../__mocks__/search_session'; import { createDiscoverServicesMock } from '../../__mocks__/services'; @@ -36,14 +35,7 @@ const discoverServices = createDiscoverServicesMock(); describe('ContextApp test', () => { const { history } = createSearchSessionMock(); const services = { - data: { - ...dataPluginMock.createStartContract(), - search: { - searchSource: { - createEmpty: jest.fn(), - }, - }, - }, + data: discoverServices.data, capabilities: { discover: { save: true, @@ -80,6 +72,8 @@ describe('ContextApp test', () => { contextLocator: { getRedirectUrl: jest.fn(() => '') }, singleDocLocator: { getRedirectUrl: jest.fn(() => '') }, profilesManager: discoverServices.profilesManager, + timefilter: discoverServices.timefilter, + uiActions: discoverServices.uiActions, } as unknown as DiscoverServices; const defaultProps = { diff --git a/src/plugins/discover/public/application/context/context_app_content.test.tsx b/src/plugins/discover/public/application/context/context_app_content.test.tsx index 5cf7856b3cb99..9c49bb7d122c4 100644 --- a/src/plugins/discover/public/application/context/context_app_content.test.tsx +++ b/src/plugins/discover/public/application/context/context_app_content.test.tsx @@ -16,12 +16,17 @@ import { SortDirection } from '@kbn/data-plugin/public'; import { UnifiedDataTable } from '@kbn/unified-data-table'; import { ContextAppContent, ContextAppContentProps } from './context_app_content'; import { LoadingStatus } from './services/context_query_state'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; import { discoverServiceMock } from '../../__mocks__/services'; import { DocTableWrapper } from '../../components/doc_table/doc_table_wrapper'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { buildDataTableRecord } from '@kbn/discover-utils'; import { act } from 'react-dom/test-utils'; +import { buildDataViewMock, deepMockedFields } from '@kbn/discover-utils/src/__mocks__'; + +const dataViewMock = buildDataViewMock({ + name: 'the-data-view', + fields: deepMockedFields, +}); describe('ContextAppContent test', () => { const mountComponent = async ({ diff --git a/src/plugins/discover/public/application/context/context_app_content.tsx b/src/plugins/discover/public/application/context/context_app_content.tsx index 403f12aa485d9..b822286ae72a9 100644 --- a/src/plugins/discover/public/application/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/context/context_app_content.tsx @@ -30,6 +30,9 @@ import { } from '@kbn/discover-utils'; import { DataLoadingState, UnifiedDataTableProps } from '@kbn/unified-data-table'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; +import { useQuerySubscriber } from '@kbn/unified-field-list'; +import useObservable from 'react-use/lib/useObservable'; +import { map } from 'rxjs'; import { DiscoverGrid } from '../../components/discover_grid'; import { getDefaultRowsPerPage } from '../../../common/constants'; import { LoadingStatus } from './services/context_query_state'; @@ -41,7 +44,12 @@ import { DocTableContext } from '../../components/doc_table/doc_table_context'; import { useDiscoverServices } from '../../hooks/use_discover_services'; import { DiscoverGridFlyout } from '../../components/discover_grid_flyout'; import { onResizeGridColumn } from '../../utils/on_resize_grid_column'; -import { useProfileAccessor } from '../../context_awareness'; +import { + DISCOVER_CELL_ACTIONS_TRIGGER, + useAdditionalCellActions, + useProfileAccessor, +} from '../../context_awareness'; +import { createDataSource } from '../../../common/data_sources'; export interface ContextAppContentProps { columns: string[]; @@ -132,6 +140,7 @@ export function ContextAppContent({ }, [setAppState] ); + const sort = useMemo(() => { return [[dataView.timeFieldName!, SortDirection.desc]]; }, [dataView]); @@ -167,6 +176,21 @@ export function ContextAppContent({ return getCellRenderers(); }, [getCellRenderersAccessor]); + const dataSource = useMemo(() => createDataSource({ dataView, query: undefined }), [dataView]); + const { filters } = useQuerySubscriber({ data: services.data }); + const timeRange = useObservable( + services.timefilter.getTimeUpdate$().pipe(map(() => services.timefilter.getTime())), + services.timefilter.getTime() + ); + + const cellActionsMetadata = useAdditionalCellActions({ + dataSource, + dataView, + query: undefined, + filters, + timeRange, + }); + return ( @@ -206,6 +230,9 @@ export function ContextAppContent({ { return [ + state.dataSource, state.query, state.sort, state.rowHeight, @@ -264,6 +272,21 @@ function DiscoverDocumentsComponent({ [documentState.esqlQueryColumns] ); + const { filters } = useQuerySubscriber({ data: services.data }); + + const timeRange = useObservable( + services.timefilter.getTimeUpdate$().pipe(map(() => services.timefilter.getTime())), + services.timefilter.getTime() + ); + + const cellActionsMetadata = useAdditionalCellActions({ + dataSource, + dataView, + query, + filters, + timeRange, + }); + const renderDocumentView = useCallback( ( hit: DataTableRecord, @@ -470,6 +493,9 @@ function DiscoverDocumentsComponent({ additionalFieldGroups={additionalFieldGroups} dataGridDensityState={density} onUpdateDataGridDensity={onUpdateDensity} + cellActionsTriggerId={DISCOVER_CELL_ACTIONS_TRIGGER.id} + cellActionsMetadata={cellActionsMetadata} + cellActionsHandling="append" /> diff --git a/src/plugins/discover/public/context_awareness/__mocks__/index.tsx b/src/plugins/discover/public/context_awareness/__mocks__/index.tsx index a50e43559c071..9c3c3d668b889 100644 --- a/src/plugins/discover/public/context_awareness/__mocks__/index.tsx +++ b/src/plugins/discover/public/context_awareness/__mocks__/index.tsx @@ -34,6 +34,18 @@ export const createContextAwarenessMocks = ({ ...prev(), rootProfile: () => <>root-profile, })), + getAdditionalCellActions: jest.fn((prev) => () => [ + ...prev(), + { + id: 'root-action', + getDisplayName: () => 'Root action', + getIconType: () => 'minus', + isCompatible: () => false, + execute: () => { + alert('Root action executed'); + }, + }, + ]), }, resolve: jest.fn(() => ({ isMatch: true, @@ -71,6 +83,17 @@ export const createContextAwarenessMocks = ({ ], rowHeight: 3, })), + getAdditionalCellActions: jest.fn((prev) => () => [ + ...prev(), + { + id: 'data-source-action', + getDisplayName: () => 'Data source action', + getIconType: () => 'plus', + execute: () => { + alert('Data source action executed'); + }, + }, + ]), }, resolve: jest.fn(() => ({ isMatch: true, diff --git a/src/plugins/discover/public/context_awareness/hooks/index.ts b/src/plugins/discover/public/context_awareness/hooks/index.ts index 70ed93f0830f2..c509fd0119059 100644 --- a/src/plugins/discover/public/context_awareness/hooks/index.ts +++ b/src/plugins/discover/public/context_awareness/hooks/index.ts @@ -9,3 +9,4 @@ export { useProfileAccessor } from './use_profile_accessor'; export { useRootProfile } from './use_root_profile'; +export { useAdditionalCellActions } from './use_additional_cell_actions'; diff --git a/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.test.tsx b/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.test.tsx new file mode 100644 index 0000000000000..befaaf7718d05 --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.test.tsx @@ -0,0 +1,251 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { act, renderHook } from '@testing-library/react-hooks'; +import { + DISCOVER_CELL_ACTION_TYPE, + createCellAction, + toCellActionContext, + useAdditionalCellActions, +} from './use_additional_cell_actions'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { discoverServiceMock } from '../../__mocks__/services'; +import React from 'react'; +import { createEsqlDataSource } from '../../../common/data_sources'; +import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield'; +import type { + Action, + ActionDefinition, + ActionExecutionContext, +} from '@kbn/ui-actions-plugin/public/actions'; +import { + DISCOVER_CELL_ACTIONS_TRIGGER, + type AdditionalCellAction, + type DiscoverCellActionExecutionContext, +} from '../types'; +import { createContextAwarenessMocks } from '../__mocks__'; +import { DataViewField } from '@kbn/data-views-plugin/common'; + +let mockUuid = 0; + +jest.mock('uuid', () => ({ ...jest.requireActual('uuid'), v4: () => (++mockUuid).toString() })); + +const mockActions: Array> = []; +const mockTriggerActions: Record = { [DISCOVER_CELL_ACTIONS_TRIGGER.id]: [] }; + +jest.spyOn(discoverServiceMock.uiActions, 'registerAction').mockImplementation((action) => { + mockActions.push(action as ActionDefinition); + return action as Action; +}); + +jest + .spyOn(discoverServiceMock.uiActions, 'attachAction') + .mockImplementation((triggerId, actionId) => { + mockTriggerActions[triggerId].push(actionId); + }); + +jest.spyOn(discoverServiceMock.uiActions, 'unregisterAction').mockImplementation((id) => { + mockActions.splice( + mockActions.findIndex((action) => action.id === id), + 1 + ); +}); + +jest + .spyOn(discoverServiceMock.uiActions, 'detachAction') + .mockImplementation((triggerId, actionId) => { + mockTriggerActions[triggerId].splice( + mockTriggerActions[triggerId].findIndex((action) => action === actionId), + 1 + ); + }); + +describe('useAdditionalCellActions', () => { + const initialProps: Parameters[0] = { + dataSource: createEsqlDataSource(), + dataView: dataViewWithTimefieldMock, + query: { esql: `FROM ${dataViewWithTimefieldMock.getIndexPattern()}` }, + filters: [], + timeRange: { from: 'now-15m', to: 'now' }, + }; + + const render = () => { + return renderHook((props) => useAdditionalCellActions(props), { + initialProps, + wrapper: ({ children }) => ( + {children} + ), + }); + }; + + beforeEach(() => { + discoverServiceMock.profilesManager = createContextAwarenessMocks().profilesManagerMock; + }); + + afterEach(() => { + mockUuid = 0; + }); + + it('should return metadata', async () => { + const { result, unmount } = render(); + expect(result.current).toEqual({ + instanceId: '1', + ...initialProps, + }); + unmount(); + }); + + it('should register and unregister cell actions', async () => { + await discoverServiceMock.profilesManager.resolveRootProfile({}); + const { rerender, result, unmount } = render(); + expect(result.current.instanceId).toEqual('1'); + expect(mockActions).toHaveLength(1); + expect(mockTriggerActions[DISCOVER_CELL_ACTIONS_TRIGGER.id]).toEqual(['root-action-2']); + await act(() => discoverServiceMock.profilesManager.resolveDataSourceProfile({})); + rerender(); + expect(result.current.instanceId).toEqual('3'); + expect(mockActions).toHaveLength(2); + expect(mockTriggerActions[DISCOVER_CELL_ACTIONS_TRIGGER.id]).toEqual([ + 'root-action-4', + 'data-source-action-5', + ]); + unmount(); + expect(mockActions).toHaveLength(0); + expect(mockTriggerActions[DISCOVER_CELL_ACTIONS_TRIGGER.id]).toEqual([]); + }); +}); + +describe('createCellAction', () => { + const context: ActionExecutionContext = { + data: [ + { + field: dataViewWithTimefieldMock.getFieldByName('message')?.toSpec()!, + value: 'test message', + }, + ], + metadata: undefined, + nodeRef: React.createRef(), + trigger: DISCOVER_CELL_ACTIONS_TRIGGER, + }; + + const getCellAction = (isCompatible?: AdditionalCellAction['isCompatible']) => { + const additional: AdditionalCellAction = { + id: 'test', + getIconType: jest.fn(() => 'plus'), + getDisplayName: jest.fn(() => 'displayName'), + execute: jest.fn(), + isCompatible, + }; + return { additional, action: createCellAction('test', additional, 0) }; + }; + + it('should create cell action', () => { + const { action } = getCellAction(); + expect(action).toEqual({ + id: 'test-1', + order: 0, + type: DISCOVER_CELL_ACTION_TYPE, + getIconType: expect.any(Function), + getDisplayName: expect.any(Function), + getDisplayNameTooltip: expect.any(Function), + execute: expect.any(Function), + isCompatible: expect.any(Function), + }); + }); + + it('should get icon type', () => { + const { additional, action } = getCellAction(); + expect(action.getIconType(context)).toEqual('plus'); + expect(additional.getIconType).toHaveBeenCalledWith(toCellActionContext(context)); + }); + + it('should get display name', () => { + const { additional, action } = getCellAction(); + expect(action.getDisplayName(context)).toEqual('displayName'); + expect(action.getDisplayNameTooltip?.(context)).toEqual('displayName'); + expect(additional.getDisplayName).toHaveBeenCalledWith(toCellActionContext(context)); + }); + + it('should execute', async () => { + const { additional, action } = getCellAction(); + await action.execute(context); + expect(additional.execute).toHaveBeenCalledWith(toCellActionContext(context)); + }); + + it('should be compatible if isCompatible is undefined', async () => { + const { action } = getCellAction(); + expect( + await action.isCompatible({ + ...context, + metadata: { instanceId: 'test', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(true); + }); + + it('should be compatible if isCompatible returns true', async () => { + const { action } = getCellAction(() => true); + expect( + await action.isCompatible({ + ...context, + metadata: { instanceId: 'test', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(true); + }); + + it('should not be compatible if isCompatible returns false', async () => { + const { action } = getCellAction(() => false); + expect( + await action.isCompatible({ + ...context, + metadata: { instanceId: 'test', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(false); + }); + + it('should not be compatible if instanceId is not equal', async () => { + const { action } = getCellAction(); + expect( + await action.isCompatible({ + ...context, + metadata: { instanceId: 'test2', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(false); + }); + + it('should not be compatible if no data', async () => { + const { action } = getCellAction(); + expect( + await action.isCompatible({ + ...context, + data: [], + metadata: { instanceId: 'test', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(false); + }); + + it("should not be compatible if field doesn't exist in data view", async () => { + const { action } = getCellAction(); + expect( + await action.isCompatible({ + ...context, + data: [ + { + field: new DataViewField({ + name: 'test', + type: 'string', + aggregatable: true, + searchable: true, + }), + }, + ], + metadata: { instanceId: 'test', dataView: dataViewWithTimefieldMock }, + }) + ).toBe(false); + }); +}); diff --git a/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.ts b/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.ts new file mode 100644 index 0000000000000..fbd87511e186b --- /dev/null +++ b/src/plugins/discover/public/context_awareness/hooks/use_additional_cell_actions.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { createCellActionFactory } from '@kbn/cell-actions/actions'; +import { useEffect, useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { + DISCOVER_CELL_ACTIONS_TRIGGER, + type AdditionalCellAction, + type AdditionalCellActionContext, + type DiscoverCellAction, + type DiscoverCellActionExecutionContext, + type DiscoverCellActionMetadata, +} from '../types'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; +import { useProfileAccessor } from './use_profile_accessor'; + +export const DISCOVER_CELL_ACTION_TYPE = 'discover-cellAction-type'; + +export const useAdditionalCellActions = ({ + dataSource, + dataView, + query, + filters, + timeRange, +}: Omit) => { + const { uiActions } = useDiscoverServices(); + const [instanceId, setInstanceId] = useState(); + const getAdditionalCellActionsAccessor = useProfileAccessor('getAdditionalCellActions'); + const additionalCellActions = useMemo( + () => getAdditionalCellActionsAccessor(() => [])(), + [getAdditionalCellActionsAccessor] + ); + + useEffect(() => { + const currentInstanceId = uuidv4(); + const actions = additionalCellActions.map((action, i) => + createCellAction(currentInstanceId, action, i) + ); + + actions.forEach((action) => { + uiActions.registerAction(action); + uiActions.attachAction(DISCOVER_CELL_ACTIONS_TRIGGER.id, action.id); + }); + + setInstanceId(currentInstanceId); + + return () => { + actions.forEach((action) => { + uiActions.detachAction(DISCOVER_CELL_ACTIONS_TRIGGER.id, action.id); + uiActions.unregisterAction(action.id); + }); + + setInstanceId(undefined); + }; + }, [additionalCellActions, uiActions]); + + return useMemo( + () => ({ instanceId, dataSource, dataView, query, filters, timeRange }), + [dataSource, dataView, filters, instanceId, query, timeRange] + ); +}; + +export const createCellAction = ( + instanceId: string, + action: AdditionalCellAction, + order: number +) => { + const createFactory = createCellActionFactory(() => ({ + type: DISCOVER_CELL_ACTION_TYPE, + getIconType: (context) => action.getIconType(toCellActionContext(context)), + getDisplayName: (context) => action.getDisplayName(toCellActionContext(context)), + getDisplayNameTooltip: (context) => action.getDisplayName(toCellActionContext(context)), + execute: async (context) => action.execute(toCellActionContext(context)), + isCompatible: async ({ data, metadata }) => { + if (metadata?.instanceId !== instanceId || data.length !== 1) { + return false; + } + + const field = data[0]?.field; + + if (!field || !metadata.dataView?.getFieldByName(field.name)) { + return false; + } + + return action.isCompatible?.({ field, ...metadata }) ?? true; + }, + })); + + const factory = createFactory(); + + return factory({ id: `${action.id}-${uuidv4()}`, order }); +}; + +export const toCellActionContext = ({ + data, + metadata, +}: DiscoverCellActionExecutionContext): AdditionalCellActionContext => ({ + ...data[0], + ...metadata, +}); diff --git a/src/plugins/discover/public/context_awareness/index.ts b/src/plugins/discover/public/context_awareness/index.ts index 4c02e08110b16..fcaec25c0f247 100644 --- a/src/plugins/discover/public/context_awareness/index.ts +++ b/src/plugins/discover/public/context_awareness/index.ts @@ -11,4 +11,4 @@ export * from './types'; export * from './profiles'; export { getMergedAccessor } from './composable_profile'; export { ProfilesManager } from './profiles_manager'; -export { useProfileAccessor, useRootProfile } from './hooks'; +export { useProfileAccessor, useRootProfile, useAdditionalCellActions } from './hooks'; diff --git a/src/plugins/discover/public/context_awareness/profile_providers/example_data_source_profile/profile.tsx b/src/plugins/discover/public/context_awareness/profile_providers/example_data_source_profile/profile.tsx index 21e8115f3a431..4b304ed2bb479 100644 --- a/src/plugins/discover/public/context_awareness/profile_providers/example_data_source_profile/profile.tsx +++ b/src/plugins/discover/public/context_awareness/profile_providers/example_data_source_profile/profile.tsx @@ -115,6 +115,27 @@ export const exampleDataSourceProfileProvider: DataSourceProfileProvider = { ], rowHeight: 5, }), + getAdditionalCellActions: (prev) => () => + [ + ...prev(), + { + id: 'example-data-source-action', + getDisplayName: () => 'Example data source action', + getIconType: () => 'plus', + execute: () => { + alert('Example data source action executed'); + }, + }, + { + id: 'another-example-data-source-action', + getDisplayName: () => 'Another example data source action', + getIconType: () => 'minus', + execute: () => { + alert('Another example data source action executed'); + }, + isCompatible: ({ field }) => field.name !== 'message', + }, + ], }, resolve: (params) => { let indexPattern: string | undefined; diff --git a/src/plugins/discover/public/context_awareness/types.ts b/src/plugins/discover/public/context_awareness/types.ts index 1942fe3a098d1..82cbbf01e8aa3 100644 --- a/src/plugins/discover/public/context_awareness/types.ts +++ b/src/plugins/discover/public/context_awareness/types.ts @@ -11,6 +11,12 @@ import type { DataView } from '@kbn/data-views-plugin/common'; import type { CustomCellRenderer, UnifiedDataTableProps } from '@kbn/unified-data-table'; import type { DocViewsRegistry } from '@kbn/unified-doc-viewer'; import type { DataTableRecord } from '@kbn/discover-utils'; +import type { CellAction, CellActionExecutionContext, CellActionsData } from '@kbn/cell-actions'; +import type { EuiIconType } from '@elastic/eui/src/components/icon/icon'; +import type { AggregateQuery, Filter, Query, TimeRange } from '@kbn/es-query'; +import type { OmitIndexSignature } from 'type-fest'; +import type { Trigger } from '@kbn/ui-actions-plugin/public'; +import type { DiscoverDataSource } from '../../common/data_sources'; export interface DocViewerExtension { title: string | undefined; @@ -43,6 +49,36 @@ export interface RowControlsExtensionParams { dataView: DataView; } +export const DISCOVER_CELL_ACTIONS_TRIGGER: Trigger = { id: 'DISCOVER_CELL_ACTIONS_TRIGGER_ID' }; + +export interface DiscoverCellActionMetadata extends Record { + instanceId?: string; + dataSource?: DiscoverDataSource; + dataView?: DataView; + query?: Query | AggregateQuery; + filters?: Filter[]; + timeRange?: TimeRange; +} + +export interface DiscoverCellActionExecutionContext extends CellActionExecutionContext { + metadata: DiscoverCellActionMetadata | undefined; +} + +export type DiscoverCellAction = CellAction; + +export type AdditionalCellActionContext = CellActionsData & + Omit, 'instanceId'>; + +export interface AdditionalCellAction { + id: string; + getDisplayName: (context: AdditionalCellActionContext) => string; + getIconType: (context: AdditionalCellActionContext) => EuiIconType; + isCompatible?: ( + context: Omit + ) => boolean | Promise; + execute: (context: AdditionalCellActionContext) => void | Promise; +} + export interface Profile { getDefaultAppState: (params: DefaultAppStateExtensionParams) => DefaultAppStateExtension; // Data grid @@ -53,6 +89,7 @@ export interface Profile { getRowAdditionalLeadingControls: ( params: RowControlsExtensionParams ) => UnifiedDataTableProps['rowAdditionalLeadingControls'] | undefined; + getAdditionalCellActions: () => AdditionalCellAction[]; // Doc viewer getDocViewer: (params: DocViewerExtensionParams) => DocViewerExtension; } diff --git a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx index f44184b9c9176..50f26bcf974b3 100644 --- a/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx +++ b/src/plugins/discover/public/embeddable/components/search_embeddable_grid_component.tsx @@ -19,6 +19,7 @@ import { } from '@kbn/discover-utils'; import { Filter } from '@kbn/es-query'; import { + FetchContext, useBatchedOptionalPublishingSubjects, useBatchedPublishingSubjects, } from '@kbn/presentation-publishing'; @@ -28,6 +29,7 @@ import { DataGridDensity, DataLoadingState, useColumns } from '@kbn/unified-data import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common'; +import useObservable from 'react-use/lib/useObservable'; import { DiscoverDocTableEmbeddable } from '../../components/doc_table/create_doc_table_embeddable'; import { useDiscoverServices } from '../../hooks/use_discover_services'; import { getSortForEmbeddable } from '../../utils'; @@ -38,9 +40,15 @@ import type { SearchEmbeddableApi, SearchEmbeddableStateManager } from '../types import { DiscoverGridEmbeddable } from './saved_search_grid'; import { getSearchEmbeddableDefaults } from '../get_search_embeddable_defaults'; import { onResizeGridColumn } from '../../utils/on_resize_grid_column'; +import { DISCOVER_CELL_ACTIONS_TRIGGER, useAdditionalCellActions } from '../../context_awareness'; +import { getTimeRangeFromFetchContext } from '../utils/update_search_source'; +import { createDataSource } from '../../../common/data_sources'; interface SavedSearchEmbeddableComponentProps { - api: SearchEmbeddableApi & { fetchWarnings$: BehaviorSubject }; + api: SearchEmbeddableApi & { + fetchWarnings$: BehaviorSubject; + fetchContext$: BehaviorSubject; + }; dataView: DataView; onAddFilter?: DocViewFilterFn; stateManager: SearchEmbeddableStateManager; @@ -61,6 +69,9 @@ export function SearchEmbeddableGridComponent({ savedSearch, savedSearchId, interceptedWarnings, + query, + filters, + fetchContext, rows, totalHitCount, columnsMeta, @@ -70,6 +81,9 @@ export function SearchEmbeddableGridComponent({ api.savedSearch$, api.savedObjectId, api.fetchWarnings$, + api.query$, + api.filters$, + api.fetchContext$, stateManager.rows, stateManager.totalHitCount, stateManager.columnsMeta, @@ -123,6 +137,25 @@ export function SearchEmbeddableGridComponent({ settings: grid, }); + const dataSource = useMemo(() => createDataSource({ dataView, query }), [dataView, query]); + const timeRange = useMemo( + () => (fetchContext ? getTimeRangeFromFetchContext(fetchContext) : undefined), + [fetchContext] + ); + + const cellActionsMetadata = useAdditionalCellActions({ + dataSource, + dataView, + query, + filters, + timeRange, + }); + + // Security Solution overrides our cell actions -- this is a temporary workaroud to keep + // things working as they do currently until we can migrate their actions to One Discover + const isInSecuritySolution = + useObservable(discoverServices.application.currentAppId$) === 'securitySolutionUI'; + const onStateEditedProps = useMemo( () => ({ onAddColumn, @@ -210,7 +243,13 @@ export function SearchEmbeddableGridComponent({ {...onStateEditedProps} settings={savedSearch.grid} ariaLabelledBy={'documentsAriaLabel'} - cellActionsTriggerId={SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID} + cellActionsTriggerId={ + isInSecuritySolution + ? SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID + : DISCOVER_CELL_ACTIONS_TRIGGER.id + } + cellActionsMetadata={isInSecuritySolution ? undefined : cellActionsMetadata} + cellActionsHandling={isInSecuritySolution ? 'replace' : 'append'} columnsMeta={columnsMeta} configHeaderRowHeight={defaults.headerRowHeight} configRowHeight={defaults.rowHeight} diff --git a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx index 9fc90bc8a942b..549b42c8a6cbe 100644 --- a/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx +++ b/src/plugins/discover/public/embeddable/get_search_embeddable_factory.tsx @@ -297,7 +297,7 @@ export const getSearchEmbeddableFactory = ({ } > { + describe('ES|QL mode', () => { + it('should render additional cell actions for logs data source', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + let alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + }); + + it('should not render incompatible cell action for message column', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 2); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + true + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + + it('should not render cell actions for incompatible data source', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-metrics | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + false + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + }); + + describe('data view mode', () => { + it('should render additional cell actions for logs data source', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + let alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + // check Surrounding docs page + await dataGrid.clickRowToggle(); + const [, surroundingActionEl] = await dataGrid.getRowActions(); + await surroundingActionEl.click(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + }); + + it('should not render incompatible cell action for message column', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 2); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + true + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + + it('should not render cell actions for incompatible data source', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-metrics'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + false + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + }); + }); +} diff --git a/test/functional/apps/discover/context_awareness/index.ts b/test/functional/apps/discover/context_awareness/index.ts index d3da15973b49b..655f4460883d1 100644 --- a/test/functional/apps/discover/context_awareness/index.ts +++ b/test/functional/apps/discover/context_awareness/index.ts @@ -43,5 +43,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./extensions/_get_doc_viewer')); loadTestFile(require.resolve('./extensions/_get_cell_renderers')); loadTestFile(require.resolve('./extensions/_get_default_app_state')); + loadTestFile(require.resolve('./extensions/_get_additional_cell_actions')); }); } diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index de8f523b268ab..a280c6556bbd7 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -173,6 +173,23 @@ export class DataGridService extends FtrService { await this.clickCellExpandButton(rowIndex, controlsCount + columnIndex); } + /** + * Clicks a cell action button within the expanded cell popover + * @param cellActionId The ID of the registered cell action + */ + public async clickCellExpandPopoverAction(cellActionId: string) { + await this.testSubjects.click(`*dataGridColumnCellAction-${cellActionId}`); + } + + /** + * Checks if a cell action button exists within the expanded cell popover + * @param cellActionId The ID of the registered cell action + * @returns If the cell action button exists + */ + public async cellExpandPopoverActionExists(cellActionId: string) { + return await this.testSubjects.exists(`*dataGridColumnCellAction-${cellActionId}`); + } + /** * Clicks grid cell 'filter for' action button * @param rowIndex data row index starting from 0 (0 means 1st row) diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_additional_cell_actions.ts b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_additional_cell_actions.ts new file mode 100644 index 0000000000000..738785357fb5e --- /dev/null +++ b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/extensions/_get_additional_cell_actions.ts @@ -0,0 +1,180 @@ +/* + * 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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import kbnRison from '@kbn/rison'; +import expect from '@kbn/expect'; +import type { FtrProviderContext } from '../../../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'common', + 'discover', + 'header', + 'unifiedFieldList', + 'svlCommonPage', + ]); + const dataViews = getService('dataViews'); + const dataGrid = getService('dataGrid'); + const browser = getService('browser'); + + describe('extension getAdditionalCellActions', () => { + before(async () => { + await PageObjects.svlCommonPage.loginAsAdmin(); + }); + + describe('ES|QL mode', () => { + it('should render additional cell actions for logs data source', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + let alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + }); + + it('should not render incompatible cell action for message column', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-logs | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 2); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + true + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + + it('should not render cell actions for incompatible data source', async () => { + const state = kbnRison.encode({ + dataSource: { type: 'esql' }, + query: { esql: 'from my-example-metrics | sort @timestamp desc' }, + }); + await PageObjects.common.navigateToActualUrl('discover', `?_a=${state}`, { + ensureCurrentUrl: false, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + false + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + }); + + describe('data view mode', () => { + it('should render additional cell actions for logs data source', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + let alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + // check Surrounding docs page + await dataGrid.clickRowToggle(); + const [, surroundingActionEl] = await dataGrid.getRowActions(); + await surroundingActionEl.click(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Example data source action executed'); + } finally { + await alert?.dismiss(); + } + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + await dataGrid.clickCellExpandPopoverAction('another-example-data-source-action'); + alert = await browser.getAlert(); + try { + expect(await alert?.getText()).to.be('Another example data source action executed'); + } finally { + await alert?.dismiss(); + } + }); + + it('should not render incompatible cell action for message column', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-logs'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 2); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + true + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + + it('should not render cell actions for incompatible data source', async () => { + await PageObjects.common.navigateToActualUrl('discover', undefined, { + ensureCurrentUrl: false, + }); + await dataViews.switchTo('my-example-metrics'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickCellExpandButtonExcludingControlColumns(0, 0); + expect(await dataGrid.cellExpandPopoverActionExists('example-data-source-action')).to.be( + false + ); + expect( + await dataGrid.cellExpandPopoverActionExists('another-example-data-source-action') + ).to.be(false); + }); + }); + }); +} diff --git a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts index d0e23c825870b..d7ce3bdc0434b 100644 --- a/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts +++ b/x-pack/test_serverless/functional/test_suites/common/discover/context_awareness/index.ts @@ -42,5 +42,6 @@ export default function ({ getService, getPageObjects, loadTestFile }: FtrProvid loadTestFile(require.resolve('./extensions/_get_doc_viewer')); loadTestFile(require.resolve('./extensions/_get_cell_renderers')); loadTestFile(require.resolve('./extensions/_get_default_app_state')); + loadTestFile(require.resolve('./extensions/_get_additional_cell_actions')); }); }