[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:
<img width="2168" alt="cell_actions"
src="https://github.com/user-attachments/assets/f01a97be-d90f-4284-9cbf-4a2e1a5dd78c">

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)
This commit is contained in:
Davis McPhee 2024-09-10 21:24:32 -03:00 committed by GitHub
parent 66af356731
commit 034625a70b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1105 additions and 49 deletions

View file

@ -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<string, unknown>;
/**
* 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<string, unknown>;
/**
* 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<UseDataGridColumnsCellActionsProps['fields']>(
() =>
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,

View file

@ -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();

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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 = <T extends DataSourceType>(
dataSource: DiscoverDataSource | undefined,
type: T

View file

@ -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',
});

View file

@ -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(),

View file

@ -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 = {

View file

@ -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 ({

View file

@ -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 (
<Fragment>
<WrapperWithPadding>
@ -206,6 +230,9 @@ export function ContextAppContent({
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<DiscoverGridMemoized
ariaLabelledBy="surDocumentsAriaLabel"
cellActionsTriggerId={DISCOVER_CELL_ACTIONS_TRIGGER.id}
cellActionsMetadata={cellActionsMetadata}
cellActionsHandling="append"
columns={columns}
rows={rows}
dataView={dataView}

View file

@ -16,7 +16,7 @@ import { buildDataTableRecord } from '@kbn/discover-utils';
import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils/types';
import type { SearchResponseWarning } from '@kbn/search-response-warnings';
import type { DiscoverServices } from '../../../build_services';
import { createDataViewDataSource } from '../../../../common/data_sources';
import { createDataSource } from '../../../../common/data_sources';
export async function fetchAnchor(
anchorId: string,
@ -34,7 +34,7 @@ export async function fetchAnchor(
const solutionNavId = await firstValueFrom(core.chrome.getActiveSolutionNavId$());
await profilesManager.resolveRootProfile({ solutionNavId });
await profilesManager.resolveDataSourceProfile({
dataSource: dataView?.id ? createDataViewDataSource({ dataViewId: dataView.id }) : undefined,
dataSource: createDataSource({ dataView, query: undefined }),
dataView,
query: { query: '', language: 'kuery' },
});

View file

@ -46,6 +46,8 @@ import {
import useObservable from 'react-use/lib/useObservable';
import type { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { DiscoverGridSettings } from '@kbn/saved-search-plugin/common';
import { useQuerySubscriber } from '@kbn/unified-field-list';
import { map } from 'rxjs';
import { DiscoverGrid } from '../../../../components/discover_grid';
import { getDefaultRowsPerPage } from '../../../../../common/constants';
import { useInternalStateSelector } from '../../state_management/discover_internal_state_container';
@ -74,7 +76,11 @@ import { onResizeGridColumn } from '../../../../utils/on_resize_grid_column';
import { useContextualGridCustomisations } from '../../hooks/grid_customisations';
import { useIsEsqlMode } from '../../hooks/use_is_esql_mode';
import { useAdditionalFieldGroups } from '../../hooks/sidebar/use_additional_field_groups';
import { useProfileAccessor } from '../../../../context_awareness';
import {
DISCOVER_CELL_ACTIONS_TRIGGER,
useAdditionalCellActions,
useProfileAccessor,
} from '../../../../context_awareness';
const containerStyles = css`
position: relative;
@ -117,6 +123,7 @@ function DiscoverDocumentsComponent({
const savedSearch = useSavedSearchInitial();
const { dataViews, capabilities, uiSettings, uiActions } = services;
const [
dataSource,
query,
sort,
rowHeight,
@ -128,6 +135,7 @@ function DiscoverDocumentsComponent({
density,
] = useAppStateSelector((state) => {
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"
/>
</CellActionsProvider>
</div>

View file

@ -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,

View file

@ -9,3 +9,4 @@
export { useProfileAccessor } from './use_profile_accessor';
export { useRootProfile } from './use_root_profile';
export { useAdditionalCellActions } from './use_additional_cell_actions';

View file

@ -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<ActionDefinition<DiscoverCellActionExecutionContext>> = [];
const mockTriggerActions: Record<string, string[]> = { [DISCOVER_CELL_ACTIONS_TRIGGER.id]: [] };
jest.spyOn(discoverServiceMock.uiActions, 'registerAction').mockImplementation((action) => {
mockActions.push(action as ActionDefinition<DiscoverCellActionExecutionContext>);
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<typeof useAdditionalCellActions>[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 }) => (
<KibanaContextProvider services={discoverServiceMock}>{children}</KibanaContextProvider>
),
});
};
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<DiscoverCellActionExecutionContext> = {
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);
});
});

View file

@ -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<AdditionalCellActionContext, 'field' | 'value'>) => {
const { uiActions } = useDiscoverServices();
const [instanceId, setInstanceId] = useState<string | undefined>();
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<DiscoverCellActionMetadata>(
() => ({ instanceId, dataSource, dataView, query, filters, timeRange }),
[dataSource, dataView, filters, instanceId, query, timeRange]
);
};
export const createCellAction = (
instanceId: string,
action: AdditionalCellAction,
order: number
) => {
const createFactory = createCellActionFactory<DiscoverCellAction>(() => ({
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,
});

View file

@ -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';

View file

@ -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;

View file

@ -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<string, unknown> {
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<DiscoverCellActionExecutionContext>;
export type AdditionalCellActionContext = CellActionsData &
Omit<OmitIndexSignature<DiscoverCellActionMetadata>, 'instanceId'>;
export interface AdditionalCellAction {
id: string;
getDisplayName: (context: AdditionalCellActionContext) => string;
getIconType: (context: AdditionalCellActionContext) => EuiIconType;
isCompatible?: (
context: Omit<AdditionalCellActionContext, 'value'>
) => boolean | Promise<boolean>;
execute: (context: AdditionalCellActionContext) => void | Promise<void>;
}
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;
}

View file

@ -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<SearchResponseIncompleteWarning[]> };
api: SearchEmbeddableApi & {
fetchWarnings$: BehaviorSubject<SearchResponseIncompleteWarning[]>;
fetchContext$: BehaviorSubject<FetchContext | undefined>;
};
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}

View file

@ -297,7 +297,7 @@ export const getSearchEmbeddableFactory = ({
}
>
<SearchEmbeddableGridComponent
api={{ ...api, fetchWarnings$ }}
api={{ ...api, fetchWarnings$, fetchContext$ }}
dataView={dataView!}
onAddFilter={isEsqlMode(savedSearch) ? undefined : onAddFilter}
stateManager={searchEmbeddable.stateManager}

View file

@ -36,13 +36,13 @@ import { SearchResponseWarning } from '@kbn/search-response-warnings';
import { SearchResponseIncompleteWarning } from '@kbn/search-response-warnings/src/types';
import { getTextBasedColumnsMeta } from '@kbn/unified-data-table';
import { createDataViewDataSource, createEsqlDataSource } from '../../common/data_sources';
import { fetchEsql } from '../application/main/data_fetching/fetch_esql';
import { DiscoverServices } from '../build_services';
import { getAllowedSampleSize } from '../utils/get_allowed_sample_size';
import { getAppTarget } from './initialize_edit_api';
import { PublishesSavedSearch, SearchEmbeddableStateManager } from './types';
import { getTimeRangeFromFetchContext, updateSearchSource } from './utils/update_search_source';
import { createDataSource } from '../../common/data_sources';
type SavedSearchPartialFetchApi = PublishesSavedSearch &
PublishesSavedObjectId &
@ -138,11 +138,7 @@ export function initializeFetch({
abortController = currentAbortController;
await discoverServices.profilesManager.resolveDataSourceProfile({
dataSource: isOfAggregateQueryType(searchSourceQuery)
? createEsqlDataSource()
: dataView.id
? createDataViewDataSource({ dataViewId: dataView.id })
: undefined,
dataSource: createDataSource({ dataView, query: searchSourceQuery }),
dataView,
query: searchSourceQuery,
});

View file

@ -53,14 +53,13 @@ import {
import { getESQLSearchProvider } from './global_search/search_provider';
import { HistoryService } from './history_service';
import type { ConfigSchema, ExperimentalFeatures } from '../server/config';
import {
DataSourceProfileService,
DocumentProfileService,
ProfilesManager,
RootProfileService,
} from './context_awareness';
import { DiscoverSetup, DiscoverSetupPlugins, DiscoverStart, DiscoverStartPlugins } from './types';
import { deserializeState } from './embeddable/utils/serialization_utils';
import { DISCOVER_CELL_ACTIONS_TRIGGER } from './context_awareness/types';
import { RootProfileService } from './context_awareness/profiles/root_profile';
import { DataSourceProfileService } from './context_awareness/profiles/data_source_profile';
import { DocumentProfileService } from './context_awareness/profiles/document_profile';
import { ProfilesManager } from './context_awareness/profiles_manager';
/**
* Contains Discover, one of the oldest parts of Kibana
@ -271,6 +270,7 @@ export class DiscoverPlugin
plugins.uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction);
plugins.uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER);
plugins.uiActions.registerTrigger(DISCOVER_CELL_ACTIONS_TRIGGER);
injectTruncateStyles(core.uiSettings.get(TRUNCATE_MAX_HEIGHT));
const isEsqlEnabled = core.uiSettings.get(ENABLE_ESQL);

View file

@ -0,0 +1,172 @@
/*
* 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 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']);
const dataViews = getService('dataViews');
const dataGrid = getService('dataGrid');
const browser = getService('browser');
describe('extension getAdditionalCellActions', () => {
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);
});
});
});
}

View file

@ -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'));
});
}

View file

@ -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)

View file

@ -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);
});
});
});
}

View file

@ -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'));
});
}