[Security Solution] Use CellActions registry in Discover data grid (#157201)

## Summary

closes: https://github.com/elastic/kibana/issues/157191

Enables Discover DataGrid to use registered cell actions instead of the
default static actions.

### New `cellActionsTriggerId` prop
This PR introduces a new `cellActionsTriggerId` _optional_ prop in the
DataGrid component:


98c210f9ec/src/plugins/discover/public/components/discover_grid/discover_grid.tsx (L198-L201)

When this prop is defined, the component queries the trigger's registry
to retrieve the cellActions attached to it, using the CellActions
package' `useDataGridColumnsCellActions` hook. This hook returns the
cellActions array ready to be passed for each column to the EuiDataGrid
component.
When (non-empty) actions are found in the registry, they are used,
replacing all of the default static Discover actions. Otherwise, the
default cell actions are used.

This new prop also allows other instances of the Discover DataGrid to be
configured with custom cell actions, which will probably be needed by
Security Timeline integration with Discover.

### New `SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER` Trigger

Along with the new `cellActionsTriggerId` prop the plugin also registers
a new trigger for "saved search" embeddable:


055750c8dd/src/plugins/discover/public/plugin.tsx (L387)

And it gets passed to the DataGrid component on the Embeddable creation:


055750c8dd/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx (L403)

Having this new trigger available allows solutions to attach custom
actions to it, in order to be displayed in the saved search embeddables.
Each action will be able to implement its `isCompatible` check to
determine if they are going to be displayed in the embedded saved search
DataGrid field, or not. If no compatible actions are found, DataGrid
will render the default static actions.


ℹ️ In this implementation, the actions registered to this new
"embeddable trigger" need to check if they are being rendered inside
Security using the `isCompatible` function, to prevent them from being
displayed in other solutions, resulting in a non-optimal architecture.
This approach was needed since there's no plausible way to pass the
`cellActionsTriggerId` property from the Dashboard Renderer used in
Security, all the way down to the specific Discover "saved search"
embeddable. However, the Dashboards team is planning to enable us to
pass options to nested embeddables using a registry
(https://github.com/elastic/kibana/issues/148933). When this new tool is
available we will be able to delegate the trigger registering to
Security and configure the "saved search" embeddables to use it.
Therefore, the trigger will only be used by Security, so we won't have
to worry about Security actions being rendered outside Security.


## Videos

before:


de92cd74-6125-4766-8e9d-7e0985932618

after:


f9bd597a-860e-4572-aa9d-9f1c72c11a4b

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Pablo Neves Machado <pablo.nevesmachado@elastic.co>
This commit is contained in:
Sergi Massaneda 2023-06-23 16:15:41 +02:00 committed by GitHub
parent 5e72c03a9a
commit f4159c4583
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1667 additions and 730 deletions

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { KBN_FIELD_TYPES } from '@kbn/field-types';
export const FILTER_CELL_ACTION_TYPE = 'cellAction-filter';
export const COPY_CELL_ACTION_TYPE = 'cellAction-copy';
@ -14,3 +16,10 @@ export enum CellActionsMode {
HOVER_RIGHT = 'hover-right',
INLINE = 'inline',
}
export const SUPPORTED_KBN_TYPES = [
KBN_FIELD_TYPES.DATE,
KBN_FIELD_TYPES.IP,
KBN_FIELD_TYPES.STRING,
KBN_FIELD_TYPES.NUMBER, // Currently supported by casting https://github.com/elastic/kibana/issues/159298
];

View file

@ -7,26 +7,30 @@
*/
import { orderBy } from 'lodash/fp';
import React, { createContext, FC, useCallback, useContext } from 'react';
import React, { createContext, FC, useContext, useMemo } from 'react';
import type { CellAction, CellActionsProviderProps, GetActions } from '../types';
const CellActionsContext = createContext<{ getActions: GetActions } | null>(null);
interface CellActionsContextValue {
getActions: GetActions;
}
const CellActionsContext = createContext<CellActionsContextValue | null>(null);
export const CellActionsProvider: FC<CellActionsProviderProps> = ({
children,
getTriggerCompatibleActions,
}) => {
const getActions = useCallback<GetActions>(
(context) =>
getTriggerCompatibleActions(context.trigger.id, context).then((actions) =>
orderBy(['order', 'id'], ['asc', 'asc'], actions)
) as Promise<CellAction[]>,
const value = useMemo<CellActionsContextValue>(
() => ({
getActions: (context) =>
getTriggerCompatibleActions(context.trigger.id, context).then((actions) =>
orderBy(['order', 'id'], ['asc', 'asc'], actions)
) as Promise<CellAction[]>,
}),
[getTriggerCompatibleActions]
);
return (
<CellActionsContext.Provider value={{ getActions }}>{children}</CellActionsContext.Provider>
);
// make sure that provider's value does not change
return <CellActionsContext.Provider value={value}>{children}</CellActionsContext.Provider>;
};
export const useCellActionsContext = () => {

View file

@ -31,30 +31,21 @@ const mockGetActions = jest.fn(async () => actions);
jest.mock('../context/cell_actions_context', () => ({
useCellActionsContext: () => ({ getActions: mockGetActions }),
}));
const values1 = ['0.0', '0.1', '0.2', '0.3'];
const field1 = {
name: 'column1',
type: 'string',
searchable: true,
aggregatable: true,
};
const values2 = ['1.0', '1.1', '1.2', '1.3'];
const field2 = {
name: 'column2',
type: 'string',
searchable: true,
aggregatable: true,
const fieldValues: Record<string, string[]> = {
column1: ['0.0', '0.1', '0.2', '0.3'],
column2: ['1.0', '1.1', '1.2', '1.3'],
};
const mockGetCellValue = jest.fn(
(field: string, rowIndex: number) => fieldValues[field]?.[rowIndex % fieldValues[field].length]
);
const field1 = { name: 'column1', type: 'text', searchable: true, aggregatable: true };
const field2 = { name: 'column2', type: 'keyword', searchable: true, aggregatable: true };
const columns = [{ id: field1.name }, { id: field2.name }];
const mockCloseCellPopover = jest.fn();
const useDataGridColumnsCellActionsProps: UseDataGridColumnsCellActionsProps = {
data: [
{ field: field1, values: values1 },
{ field: field2, values: values2 },
],
fields: [field1, field2],
getCellValue: mockGetCellValue,
triggerId: 'testTriggerId',
metadata: { some: 'value' },
dataGridRef: {
@ -101,13 +92,31 @@ describe('useDataGridColumnsCellActions', () => {
const { result } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await act(async () => {
const cellAction = renderCellAction(result.current[0][0]);
expect(cellAction.getByTestId('dataGridColumnCellAction-loading')).toBeInTheDocument();
});
});
it('should call getCellValue with the proper params', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
});
await waitForNextUpdate();
renderCellAction(result.current[0][0], { rowIndex: 0 });
renderCellAction(result.current[0][1], { rowIndex: 1 });
renderCellAction(result.current[1][0], { rowIndex: 0 });
renderCellAction(result.current[1][1], { rowIndex: 1 });
expect(mockGetCellValue).toHaveBeenCalledTimes(4);
expect(mockGetCellValue).toHaveBeenCalledWith(field1.name, 0);
expect(mockGetCellValue).toHaveBeenCalledWith(field1.name, 1);
expect(mockGetCellValue).toHaveBeenCalledWith(field2.name, 0);
expect(mockGetCellValue).toHaveBeenCalledWith(field2.name, 1);
});
it('should render the cell actions', async () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: useDataGridColumnsCellActionsProps,
@ -156,7 +165,7 @@ describe('useDataGridColumnsCellActions', () => {
expect.objectContaining({
data: [
{
value: values1[1],
value: fieldValues[field1.name][1],
field: {
name: field1.name,
type: field1.type,
@ -179,7 +188,7 @@ describe('useDataGridColumnsCellActions', () => {
expect.objectContaining({
data: [
{
value: values2[2],
value: fieldValues[field2.name][2],
field: {
name: field2.name,
type: field2.type,
@ -204,12 +213,14 @@ describe('useDataGridColumnsCellActions', () => {
cellAction.getByTestId(`dataGridColumnCellAction-${action1.id}`).click();
expect(mockGetCellValue).toHaveBeenCalledWith(field1.name, 25);
await waitFor(() => {
expect(action1.execute).toHaveBeenCalledWith(
expect.objectContaining({
data: [
{
value: values1[1],
value: fieldValues[field1.name][1],
field: {
name: field1.name,
type: field1.type,
@ -242,7 +253,7 @@ describe('useDataGridColumnsCellActions', () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: {
...useDataGridColumnsCellActionsProps,
data: [],
fields: [],
},
});
@ -256,7 +267,7 @@ describe('useDataGridColumnsCellActions', () => {
const { result, waitForNextUpdate } = renderHook(useDataGridColumnsCellActions, {
initialProps: {
...useDataGridColumnsCellActionsProps,
data: undefined,
fields: undefined,
},
});

View file

@ -12,47 +12,66 @@ import {
EuiLoadingSpinner,
type EuiDataGridColumnCellAction,
} from '@elastic/eui';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import type {
CellAction,
CellActionCompatibilityContext,
CellActionExecutionContext,
CellActionsData,
CellActionsProps,
CellActionFieldValue,
} from '../types';
import { useBulkLoadActions } from './use_load_actions';
interface BulkData extends Omit<CellActionsData, 'value'> {
/**
* Array containing all the values of the field in the visible page, indexed by rowIndex
*/
values: Array<string | string[] | null | undefined>;
}
export interface UseDataGridColumnsCellActionsProps
extends Pick<CellActionsProps, 'triggerId' | 'metadata' | 'disabledActionTypes'> {
data?: BulkData[];
extends Pick<CellActionsProps, 'metadata' | 'disabledActionTypes'> {
/**
* Optional trigger ID to used to retrieve the cell actions.
* returns empty array if not provided
*/
triggerId?: string;
/**
* fields array, used to determine which actions to load.
* returns empty array if not provided
*/
fields?: FieldSpec[];
/**
* Function to get the cell value for a given field name and row index.
* the `rowIndex` parameter is absolute, not relative to the current page
*/
getCellValue: (fieldName: string, rowIndex: number) => CellActionFieldValue;
/**
* ref to the EuiDataGrid instance
*/
dataGridRef: MutableRefObject<EuiDataGridRefProps | null>;
}
export type UseDataGridColumnsCellActions<
P extends UseDataGridColumnsCellActionsProps = UseDataGridColumnsCellActionsProps
> = (props: P) => EuiDataGridColumnCellAction[][];
// static actions array references to prevent React updates
const loadingColumnActions: EuiDataGridColumnCellAction[] = [
() => <EuiLoadingSpinner size="s" data-test-subj="dataGridColumnCellAction-loading" />,
];
const emptyActions: EuiDataGridColumnCellAction[][] = [];
export const useDataGridColumnsCellActions: UseDataGridColumnsCellActions = ({
data,
fields,
getCellValue,
triggerId,
metadata,
dataGridRef,
disabledActionTypes = [],
}) => {
const bulkContexts: CellActionCompatibilityContext[] = useMemo(
() =>
data?.map(({ field }) => ({
data: [{ field }], // we are getting the actions for the whole column field, so the compatibility check will be done without the value
trigger: { id: triggerId },
metadata,
})) ?? [],
[triggerId, metadata, data]
);
const bulkContexts: CellActionCompatibilityContext[] | undefined = useMemo(() => {
if (!triggerId || !fields?.length) {
return undefined;
}
return fields.map((field) => ({
data: [{ field }],
trigger: { id: triggerId },
metadata,
}));
}, [fields, triggerId, metadata]);
const { loading, value: columnsActions } = useBulkLoadActions(bulkContexts, {
disabledActionTypes,
@ -60,46 +79,45 @@ export const useDataGridColumnsCellActions: UseDataGridColumnsCellActions = ({
const columnsCellActions = useMemo<EuiDataGridColumnCellAction[][]>(() => {
if (loading) {
return (
data?.map(() => [
() => <EuiLoadingSpinner size="s" data-test-subj="dataGridColumnCellAction-loading" />,
]) ?? []
);
return fields?.length ? fields.map(() => loadingColumnActions) : emptyActions;
}
if (!columnsActions || !data || data.length === 0) {
return [];
if (!triggerId || !columnsActions?.length || !fields?.length) {
return emptyActions;
}
// Check for a temporary inconsistency because `useBulkLoadActions` takes one render loop before setting `loading` to true.
// It will eventually update to a consistent state
if (columnsActions.length !== data.length) {
return [];
if (columnsActions.length !== fields.length) {
return emptyActions;
}
return columnsActions.map((actions, columnIndex) =>
actions.map((action) =>
createColumnCellAction({
action,
field: fields[columnIndex],
getCellValue,
metadata,
triggerId,
data: data[columnIndex],
dataGridRef,
})
)
);
}, [loading, columnsActions, data, metadata, triggerId, dataGridRef]);
}, [columnsActions, fields, getCellValue, loading, metadata, triggerId, dataGridRef]);
return columnsCellActions;
};
interface CreateColumnCellActionParams
extends Pick<UseDataGridColumnsCellActionsProps, 'triggerId' | 'metadata' | 'dataGridRef'> {
data: BulkData;
extends Pick<UseDataGridColumnsCellActionsProps, 'getCellValue' | 'metadata' | 'dataGridRef'> {
field: FieldSpec;
triggerId: string;
action: CellAction;
}
const createColumnCellAction = ({
data: { field, values },
action,
field,
getCellValue,
metadata,
triggerId,
dataGridRef,
@ -109,8 +127,8 @@ const createColumnCellAction = ({
const buttonRef = useRef<HTMLAnchorElement | null>(null);
const actionContext: CellActionExecutionContext = useMemo(() => {
// rowIndex refers to all pages, we need to use the row index relative to the page to get the value
const value = values[rowIndex % values.length];
const { name } = field;
const value = getCellValue(name, rowIndex);
return {
data: [
{

View file

@ -199,5 +199,24 @@ describe('loadActions hooks', () => {
expect(result.current.value).toHaveLength(0);
expect(result.current.loading).toBe(false);
});
it('should return the same array after re-render when contexts is undefined', async () => {
const { result, rerender, waitFor } = renderHook(useBulkLoadActions, {
initialProps: undefined,
});
await waitFor(() => expect(result.current.value).toEqual([]));
expect(result.current.loading).toBe(false);
expect(mockGetActions).not.toHaveBeenCalled();
const initialResultValue = result.current.value;
rerender(undefined);
await waitFor(() => expect(result.current.value).toEqual([]));
expect(result.current.value).toBe(initialResultValue);
expect(result.current.loading).toBe(false);
expect(mockGetActions).not.toHaveBeenCalled();
});
});
});

View file

@ -53,18 +53,18 @@ interface LoadActionsOptions {
* Groups getActions calls for an array of contexts in one async bulk operation
*/
export const useBulkLoadActions = (
contexts: CellActionCompatibilityContext[],
contexts: CellActionCompatibilityContext[] | undefined,
options: LoadActionsOptions = {}
): AsyncActions<CellAction[][]> => {
const { getActions } = useCellActionsContext();
const { error, ...actionsState } = useAsync(
() =>
Promise.all(
contexts.map((context) =>
contexts?.map((context) =>
getActions(context).then(
(actions) => filteredActions(actions, options.disabledActionTypes) ?? []
)
)
) ?? []
),
[contexts]
);

View file

@ -9,6 +9,7 @@
// Types and enums
export type {
CellAction,
CellActionFieldValue,
CellActionsProps,
CellActionExecutionContext,
CellActionCompatibilityContext,

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { isTypeSupportedByCellActions } from './utils';
describe('isTypeSupportedByCellActions', () => {
it('returns true if the type is number', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.NUMBER)).toBe(true);
});
it('returns true if the type is string', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.STRING)).toBe(true);
});
it('returns true if the type is ip', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.IP)).toBe(true);
});
it('returns true if the type is date', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.DATE)).toBe(true);
});
it('returns false if the type is boolean', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.BOOLEAN)).toBe(false);
});
it('returns false if the type is unknown', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.BOOLEAN)).toBe(false);
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { SUPPORTED_KBN_TYPES } from './constants';
export const isTypeSupportedByCellActions = (kbnFieldType: KBN_FIELD_TYPES) =>
SUPPORTED_KBN_TYPES.includes(kbnFieldType);

View file

@ -19,6 +19,7 @@
"@kbn/data-plugin",
"@kbn/es-query",
"@kbn/ui-actions-plugin",
"@kbn/field-types",
"@kbn/data-views-plugin",
],
"exclude": ["target/**/*"]

View file

@ -8,7 +8,7 @@
import { DataView } from '@kbn/data-views-plugin/public';
const fields = [
export const fields = [
{
name: '_source',
type: '_source',

View file

@ -9,6 +9,7 @@ import { Observable, of } from 'rxjs';
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { DiscoverServices } from '../build_services';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
import { savedSearchPluginMock } from '@kbn/saved-search-plugin/public/mocks';
import { chromeServiceMock, coreMock, docLinksServiceMock } from '@kbn/core/public/mocks';
@ -120,6 +121,7 @@ export function createDiscoverServicesMock(): DiscoverServices {
inspector: {
open: jest.fn(),
},
uiActions: uiActionsPluginMock.createStartContract(),
uiSettings: {
get: jest.fn((key: string) => {
if (key === 'fields:popularLimit') {

View file

@ -15,14 +15,15 @@ import { SortDirection } from '@kbn/data-plugin/public';
import { ContextAppContent, ContextAppContentProps } from './context_app_content';
import { LoadingStatus } from './services/context_query_state';
import { dataViewMock } from '../../__mocks__/data_view';
import { DiscoverGrid } from '../../components/discover_grid/discover_grid';
import { discoverServiceMock } from '../../__mocks__/services';
import { DiscoverGrid } from '../../components/discover_grid/discover_grid';
import { DocTableWrapper } from '../../components/doc_table/doc_table_wrapper';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '../../utils/build_data_record';
import { act } from 'react-dom/test-utils';
describe('ContextAppContent test', () => {
const mountComponent = ({
const mountComponent = async ({
anchorStatus,
isLegacy,
}: {
@ -73,30 +74,35 @@ describe('ContextAppContent test', () => {
addFilter: () => {},
} as unknown as ContextAppContentProps;
return mountWithIntl(
const component = mountWithIntl(
<KibanaContextProvider services={discoverServiceMock}>
<ContextAppContent {...props} />
</KibanaContextProvider>
);
await act(async () => {
// needed by cell actions to complete async loading
component.update();
});
return component;
};
it('should render legacy table correctly', () => {
const component = mountComponent({});
it('should render legacy table correctly', async () => {
const component = await mountComponent({});
expect(component.find(DocTableWrapper).length).toBe(1);
const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator');
expect(loadingIndicator.length).toBe(0);
expect(component.find(ActionBar).length).toBe(2);
});
it('renders loading indicator', () => {
const component = mountComponent({ anchorStatus: LoadingStatus.LOADING });
it('renders loading indicator', async () => {
const component = await mountComponent({ anchorStatus: LoadingStatus.LOADING });
const loadingIndicator = findTestSubject(component, 'contextApp_loadingIndicator');
expect(component.find(DocTableWrapper).length).toBe(1);
expect(loadingIndicator.length).toBe(1);
});
it('should render discover grid correctly', () => {
const component = mountComponent({ isLegacy: false });
it('should render discover grid correctly', async () => {
const component = await mountComponent({ isLegacy: false });
expect(component.find(DiscoverGrid).length).toBe(1);
});
});

View file

@ -12,6 +12,7 @@ import { EuiHorizontalRule, EuiText } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import { SortDirection } from '@kbn/data-plugin/public';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import { CONTEXT_STEP_SETTING, DOC_HIDE_TIME_COLUMN_SETTING } from '../../../common';
import { LoadingStatus } from './services/context_query_state';
import { ActionBar } from './components/action_bar/action_bar';
@ -75,7 +76,7 @@ export function ContextAppContent({
setAppState,
addFilter,
}: ContextAppContentProps) {
const { uiSettings: config } = useDiscoverServices();
const { uiSettings: config, uiActions } = useDiscoverServices();
const services = useDiscoverServices();
const [expandedDoc, setExpandedDoc] = useState<DataTableRecord | undefined>();
@ -145,28 +146,30 @@ export function ContextAppContent({
)}
{!isLegacy && (
<div className="dscDocsGrid">
<DiscoverGridMemoized
ariaLabelledBy="surDocumentsAriaLabel"
columns={columns}
rows={rows}
dataView={dataView}
expandedDoc={expandedDoc}
isLoading={isAnchorLoading}
sampleSize={0}
sort={sort as SortOrder[]}
isSortEnabled={false}
showTimeCol={showTimeCol}
useNewFieldsApi={useNewFieldsApi}
isPaginationEnabled={false}
controlColumnIds={controlColumnIds}
setExpandedDoc={setExpandedDoc}
onFilter={addFilter}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
DocumentView={DiscoverGridFlyout}
services={services}
/>
<CellActionsProvider getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}>
<DiscoverGridMemoized
ariaLabelledBy="surDocumentsAriaLabel"
columns={columns}
rows={rows}
dataView={dataView}
expandedDoc={expandedDoc}
isLoading={isAnchorLoading}
sampleSize={0}
sort={sort as SortOrder[]}
isSortEnabled={false}
showTimeCol={showTimeCol}
useNewFieldsApi={useNewFieldsApi}
isPaginationEnabled={false}
controlColumnIds={controlColumnIds}
setExpandedDoc={setExpandedDoc}
onFilter={addFilter}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
DocumentView={DiscoverGridFlyout}
services={services}
/>
</CellActionsProvider>
</div>
)}
<EuiHorizontalRule margin="xs" />

View file

@ -7,6 +7,7 @@
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { BehaviorSubject } from 'rxjs';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { setHeaderActionMenuMounter } from '../../../../kibana_services';
@ -25,7 +26,7 @@ import { DiscoverAppState } from '../../services/discover_app_state_container';
setHeaderActionMenuMounter(jest.fn());
function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) {
async function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) {
const services = discoverServiceMock;
services.data.query.timefilter.timefilter.getTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
@ -46,30 +47,34 @@ function mountComponent(fetchStatus: FetchStatus, hits: EsHitRecord[]) {
onFieldEdited: jest.fn(),
};
return mountWithIntl(
const component = mountWithIntl(
<KibanaContextProvider services={services}>
<DiscoverMainProvider value={stateContainer}>
<DiscoverDocuments {...props} />
</DiscoverMainProvider>
</KibanaContextProvider>
);
await act(async () => {
component.update();
});
return component;
}
describe('Discover documents layout', () => {
test('render loading when loading and no documents', () => {
const component = mountComponent(FetchStatus.LOADING, []);
test('render loading when loading and no documents', async () => {
const component = await mountComponent(FetchStatus.LOADING, []);
expect(component.find('.dscDocuments__loading').exists()).toBeTruthy();
expect(component.find('.dscTable').exists()).toBeFalsy();
});
test('render complete when loading but documents were already fetched', () => {
const component = mountComponent(FetchStatus.LOADING, esHits);
test('render complete when loading but documents were already fetched', async () => {
const component = await mountComponent(FetchStatus.LOADING, esHits);
expect(component.find('.dscDocuments__loading').exists()).toBeFalsy();
expect(component.find('.dscTable').exists()).toBeTruthy();
});
test('render complete', () => {
const component = mountComponent(FetchStatus.COMPLETE, esHits);
test('render complete', async () => {
const component = await mountComponent(FetchStatus.COMPLETE, esHits);
expect(component.find('.dscDocuments__loading').exists()).toBeFalsy();
expect(component.find('.dscTable').exists()).toBeTruthy();
});

View file

@ -18,6 +18,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { DataView } from '@kbn/data-views-plugin/public';
import { SortOrder } from '@kbn/saved-search-plugin/public';
import { CellActionsProvider } from '@kbn/cell-actions';
import { useInternalStateSelector } from '../../services/discover_internal_state_container';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
@ -85,7 +86,7 @@ function DiscoverDocumentsComponent({
const services = useDiscoverServices();
const documents$ = stateContainer.dataState.data$.documents$;
const savedSearch = useSavedSearchInitial();
const { dataViews, capabilities, uiSettings } = services;
const { dataViews, capabilities, uiSettings, uiActions } = services;
const [query, sort, rowHeight, rowsPerPage, grid, columns, index] = useAppStateSelector(
(state) => {
return [
@ -228,39 +229,43 @@ function DiscoverDocumentsComponent({
</DiscoverTourProvider>
)}
<div className="dscDiscoverGrid">
<DataGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
isLoading={isDataLoading}
rows={rows}
sort={(sort as SortOrder[]) || []}
sampleSize={sampleSize}
searchDescription={savedSearch.description}
searchTitle={savedSearch.title}
setExpandedDoc={setExpandedDoc}
showTimeCol={showTimeCol}
settings={grid}
onAddColumn={onAddColumn}
onFilter={onAddFilter as DocViewFilterFn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
onSort={!isPlainRecord ? onSort : undefined}
onResize={onResizeDataGrid}
useNewFieldsApi={useNewFieldsApi}
rowHeightState={rowHeight}
onUpdateRowHeight={onUpdateRowHeight}
isSortEnabled={true}
isPlainRecord={isPlainRecord}
query={query}
rowsPerPageState={rowsPerPage}
onUpdateRowsPerPage={onUpdateRowsPerPage}
onFieldEdited={onFieldEdited}
savedSearchId={savedSearch.id}
DocumentView={DiscoverGridFlyout}
services={services}
/>
<CellActionsProvider
getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}
>
<DataGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
isLoading={isDataLoading}
rows={rows}
sort={(sort as SortOrder[]) || []}
sampleSize={sampleSize}
searchDescription={savedSearch.description}
searchTitle={savedSearch.title}
setExpandedDoc={setExpandedDoc}
showTimeCol={showTimeCol}
settings={grid}
onAddColumn={onAddColumn}
onFilter={onAddFilter as DocViewFilterFn}
onRemoveColumn={onRemoveColumn}
onSetColumns={onSetColumns}
onSort={!isPlainRecord ? onSort : undefined}
onResize={onResizeDataGrid}
useNewFieldsApi={useNewFieldsApi}
rowHeightState={rowHeight}
onUpdateRowHeight={onUpdateRowHeight}
isSortEnabled={true}
isPlainRecord={isPlainRecord}
query={query}
rowsPerPageState={rowsPerPage}
onUpdateRowsPerPage={onUpdateRowsPerPage}
onFieldEdited={onFieldEdited}
savedSearchId={savedSearch.id}
DocumentView={DiscoverGridFlyout}
services={services}
/>
</CellActionsProvider>
</div>
</>
)}

View file

@ -21,7 +21,8 @@ import {
} from '../../services/discover_data_state_container';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { DiscoverHistogramLayout, DiscoverHistogramLayoutProps } from './discover_histogram_layout';
import { SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/public';
@ -141,7 +142,9 @@ const mountComponent = async ({
// wait for lazy modules
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
component.update();
await act(async () => {
component.update();
});
return { component, stateContainer };
};

View file

@ -8,6 +8,7 @@
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { esHits } from '../../../../__mocks__/es_hits';
import { dataViewMock } from '../../../../__mocks__/data_view';
@ -20,7 +21,8 @@ import {
} from '../../services/discover_data_state_container';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { DiscoverMainContent, DiscoverMainContentProps } from './discover_main_content';
import { SavedSearch, VIEW_MODE } from '@kbn/saved-search-plugin/public';
@ -33,7 +35,7 @@ import { DiscoverMainProvider } from '../../services/discover_state_provider';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import type { Storage } from '@kbn/kibana-utils-plugin/public';
const mountComponent = ({
const mountComponent = async ({
hideChart = false,
isPlainRecord = false,
viewMode = VIEW_MODE.DOCUMENT_LEVEL,
@ -116,31 +118,35 @@ const mountComponent = ({
</KibanaContextProvider>
);
await act(async () => {
component.update();
});
return component;
};
describe('Discover main content component', () => {
describe('DocumentViewModeToggle', () => {
it('should show DocumentViewModeToggle when isPlainRecord is false', async () => {
const component = mountComponent();
const component = await mountComponent();
expect(component.find(DocumentViewModeToggle).exists()).toBe(true);
});
it('should not show DocumentViewModeToggle when isPlainRecord is true', async () => {
const component = mountComponent({ isPlainRecord: true });
const component = await mountComponent({ isPlainRecord: true });
expect(component.find(DocumentViewModeToggle).exists()).toBe(false);
});
});
describe('Document view', () => {
it('should show DiscoverDocuments when VIEW_MODE is DOCUMENT_LEVEL', async () => {
const component = mountComponent();
const component = await mountComponent();
expect(component.find(DiscoverDocuments).exists()).toBe(true);
expect(component.find(FieldStatisticsTab).exists()).toBe(false);
});
it('should show FieldStatisticsTableMemoized when VIEW_MODE is not DOCUMENT_LEVEL', async () => {
const component = mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
const component = await mountComponent({ viewMode: VIEW_MODE.AGGREGATED_LEVEL });
expect(component.find(DiscoverDocuments).exists()).toBe(false);
expect(component.find(FieldStatisticsTab).exists()).toBe(true);
});

View file

@ -48,6 +48,7 @@ import type { SavedObjectsTaggingApi } from '@kbn/saved-objects-tagging-oss-plug
import type { SavedObjectsManagementPluginStart } from '@kbn/saved-objects-management-plugin/public';
import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public';
import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { SettingsStart } from '@kbn/core-ui-settings-browser';
import { getHistory } from './kibana_services';
import { DiscoverStartPlugins } from './plugin';
@ -103,6 +104,7 @@ export interface DiscoverServices {
savedSearch: SavedSearchPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
lens: LensPublicStart;
uiActions: UiActionsStart;
}
export const buildServices = memoize(function (
@ -159,5 +161,6 @@ export const buildServices = memoize(function (
savedSearch: plugins.savedSearch,
unifiedSearch: plugins.unifiedSearch,
lens: plugins.lens,
uiActions: plugins.uiActions,
};
});

View file

@ -11,7 +11,7 @@ import { EuiCopy } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { findTestSubject } from '@elastic/eui/lib/test';
import { esHits } from '../../__mocks__/es_hits';
import { dataViewMock } from '../../__mocks__/data_view';
import { buildDataViewMock, fields } from '../../__mocks__/data_view';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DiscoverGrid, DiscoverGridProps } from './discover_grid';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
@ -20,6 +20,18 @@ import { buildDataTableRecord } from '../../utils/build_data_record';
import { getDocId } from '../../utils/get_doc_id';
import { EsHitRecord } from '../../types';
const mockUseDataGridColumnsCellActions = jest.fn((prop: unknown) => []);
jest.mock('@kbn/cell-actions', () => ({
...jest.requireActual('@kbn/cell-actions'),
useDataGridColumnsCellActions: (prop: unknown) => mockUseDataGridColumnsCellActions(prop),
}));
export const dataViewMock = buildDataViewMock({
name: 'the-data-view',
fields,
timeFieldName: '@timestamp',
});
function getProps() {
const services = discoverServiceMock;
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn().mockReturnValue(true);
@ -49,14 +61,19 @@ function getProps() {
};
}
function getComponent(props: DiscoverGridProps = getProps()) {
async function getComponent(props: DiscoverGridProps = getProps()) {
const Proxy = (innerProps: DiscoverGridProps) => (
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGrid {...innerProps} />
</KibanaContextProvider>
);
return mountWithIntl(<Proxy {...props} />);
const component = mountWithIntl(<Proxy {...props} />);
await act(async () => {
// needed by cell actions to complete async loading
component.update();
});
return component;
}
function getSelectedDocNr(component: ReactWrapper<DiscoverGridProps>) {
@ -89,10 +106,14 @@ async function toggleDocSelection(
}
describe('DiscoverGrid', () => {
afterEach(async () => {
jest.clearAllMocks();
});
describe('Document selection', () => {
let component: ReactWrapper<DiscoverGridProps>;
beforeEach(() => {
component = getComponent();
beforeEach(async () => {
component = await getComponent();
});
test('no documents are selected initially', async () => {
@ -178,8 +199,8 @@ describe('DiscoverGrid', () => {
});
describe('edit field button', () => {
it('should render the edit field button if onFieldEdited is provided', () => {
const component = getComponent({
it('should render the edit field button if onFieldEdited is provided', async () => {
const component = await getComponent({
...getProps(),
columns: ['message'],
onFieldEdited: jest.fn(),
@ -194,8 +215,8 @@ describe('DiscoverGrid', () => {
expect(findTestSubject(component, 'gridEditFieldButton').exists()).toBe(true);
});
it('should not render the edit field button if onFieldEdited is not provided', () => {
const component = getComponent({
it('should not render the edit field button if onFieldEdited is not provided', async () => {
const component = await getComponent({
...getProps(),
columns: ['message'],
});
@ -210,9 +231,91 @@ describe('DiscoverGrid', () => {
});
});
describe('cellActionsTriggerId', () => {
it('should call useDataGridColumnsCellActions with empty params when no cellActionsTriggerId is provided', async () => {
await getComponent({
...getProps(),
columns: ['message'],
onFieldEdited: jest.fn(),
});
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
expect.objectContaining({
triggerId: undefined,
getCellValue: expect.any(Function),
fields: undefined,
})
);
});
it('should call useDataGridColumnsCellActions properly when cellActionsTriggerId defined', async () => {
await getComponent({
...getProps(),
columns: ['message'],
onFieldEdited: jest.fn(),
cellActionsTriggerId: 'test',
});
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
expect.objectContaining({
triggerId: 'test',
getCellValue: expect.any(Function),
fields: [
{
name: '@timestamp',
type: 'date',
aggregatable: true,
searchable: undefined,
},
{
name: 'message',
type: 'string',
aggregatable: false,
searchable: undefined,
},
],
})
);
});
it('should call useDataGridColumnsCellActions with empty field name and type for unsupported field types', async () => {
await getComponent({
...getProps(),
columns: ['message', '_source'],
onFieldEdited: jest.fn(),
cellActionsTriggerId: 'test',
});
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
expect.objectContaining({
triggerId: 'test',
getCellValue: expect.any(Function),
fields: [
{
name: '@timestamp',
type: 'date',
aggregatable: true,
searchable: undefined,
},
{
name: 'message',
type: 'string',
aggregatable: false,
searchable: undefined,
},
{
searchable: false,
aggregatable: false,
name: '',
type: '',
},
],
})
);
});
});
describe('sorting', () => {
it('should enable in memory sorting with plain records', () => {
const component = getComponent({
it('should enable in memory sorting with plain records', async () => {
const component = await getComponent({
...getProps(),
columns: ['message'],
isPlainRecord: true,

View file

@ -25,10 +25,17 @@ import {
} from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
import {
useDataGridColumnsCellActions,
type UseDataGridColumnsCellActionsProps,
type CellActionFieldValue,
} from '@kbn/cell-actions';
import type { AggregateQuery, Filter, Query } from '@kbn/es-query';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { ToastsStart, IUiSettingsClient, HttpStart, CoreStart } from '@kbn/core/public';
import { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import { KBN_FIELD_TYPES } from '@kbn/data-plugin/common';
import { isTypeSupportedByCellActions } from '@kbn/cell-actions/src/utils';
import { DocViewFilterFn } from '../../services/doc_views/doc_views_types';
import { getSchemaDetectors } from './discover_grid_schema';
import { DiscoverGridFlyout } from './discover_grid_flyout';
@ -53,6 +60,7 @@ import type { DataTableRecord, ValueToStringConverter } from '../../types';
import { useRowHeightsOptions } from '../../hooks/use_row_heights_options';
import { convertValueToString } from '../../utils/convert_value_to_string';
import { getRowsPerPageOptions, getDefaultRowsPerPage } from '../../utils/rows_per_page';
import { convertCellActionValue } from './discover_grid_cell_actions';
const themeDefault = { darkMode: false };
@ -203,6 +211,10 @@ export interface DiscoverGridProps {
* Document detail view component
*/
DocumentView?: typeof DiscoverGridFlyout;
/**
* Optional triggerId to retrieve the column cell actions that will override the default ones
*/
cellActionsTriggerId?: string;
/**
* Service dependencies
*/
@ -248,6 +260,7 @@ export const DiscoverGrid = ({
isSortEnabled = true,
isPaginationEnabled = true,
controlColumnIds = CONTROL_COLUMN_IDS_DEFAULT,
cellActionsTriggerId,
className,
rowHeightState,
onUpdateRowHeight,
@ -432,14 +445,57 @@ export const DiscoverGrid = ({
[dataView, onFieldEdited, services.dataViewFieldEditor]
);
const visibleColumns = useMemo(
() => getVisibleColumns(displayedColumns, dataView, showTimeCol) as string[],
[dataView, displayedColumns, showTimeCol]
);
const getCellValue = useCallback<UseDataGridColumnsCellActionsProps['getCellValue']>(
(fieldName, rowIndex): CellActionFieldValue =>
convertCellActionValue(displayedRows[rowIndex % displayedRows.length].flattened[fieldName]),
[displayedRows]
);
const cellActionsFields = useMemo<UseDataGridColumnsCellActionsProps['fields']>(
() =>
cellActionsTriggerId && !isPlainRecord
? visibleColumns.map((columnName) => {
const field = dataView.getFieldByName(columnName);
if (!field || !isTypeSupportedByCellActions(field.type as KBN_FIELD_TYPES)) {
// disable custom actions on object columns
return {
name: '',
type: '',
aggregatable: false,
searchable: false,
};
}
return {
name: columnName,
type: field.type,
aggregatable: field.aggregatable,
searchable: field.searchable,
};
})
: undefined,
[cellActionsTriggerId, isPlainRecord, visibleColumns, dataView]
);
const columnsCellActions = useDataGridColumnsCellActions({
fields: cellActionsFields,
getCellValue,
triggerId: cellActionsTriggerId,
dataGridRef,
});
const euiGridColumns = useMemo(
() =>
getEuiGridColumns({
columns: displayedColumns,
columns: visibleColumns,
columnsCellActions,
rowsCount: displayedRows.length,
settings,
dataView,
showTimeCol,
defaultColumns,
isSortEnabled,
isPlainRecord,
@ -454,10 +510,10 @@ export const DiscoverGrid = ({
}),
[
onFilter,
displayedColumns,
visibleColumns,
columnsCellActions,
displayedRows,
dataView,
showTimeCol,
settings,
defaultColumns,
isSortEnabled,
@ -477,12 +533,12 @@ export const DiscoverGrid = ({
const schemaDetectors = useMemo(() => getSchemaDetectors(), []);
const columnsVisibility = useMemo(
() => ({
visibleColumns: getVisibleColumns(displayedColumns, dataView, showTimeCol) as string[],
visibleColumns,
setVisibleColumns: (newColumns: string[]) => {
onSetColumns(newColumns, hideTimeColumn);
},
}),
[displayedColumns, dataView, showTimeCol, hideTimeColumn, onSetColumns]
[visibleColumns, hideTimeColumn, onSetColumns]
);
const sorting = useMemo(() => {
if (isSortEnabled) {

View file

@ -10,6 +10,7 @@ import React, { useContext } from 'react';
import { EuiDataGridColumnCellActionProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DataViewField } from '@kbn/data-views-plugin/public';
import type { CellActionFieldValue } from '@kbn/cell-actions';
import { DocViewFilterFn } from '../../services/doc_views/doc_views_types';
import { DiscoverGridContext, GridContext } from './discover_grid_context';
import { useDiscoverServices } from '../../hooks/use_discover_services';
@ -120,3 +121,12 @@ export const CopyBtn = ({ Component, rowIndex, columnId }: EuiDataGridColumnCell
export function buildCellActions(field: DataViewField, onFilter?: DocViewFilterFn) {
return [...(onFilter && field.filterable ? [FilterInBtn, FilterOutBtn] : []), CopyBtn];
}
// Converts the cell action value to the type expected by CellActions component
export const convertCellActionValue = (rawValue: unknown): CellActionFieldValue => {
const value = rawValue as CellActionFieldValue | number | number[];
if (Array.isArray(value)) {
return value.map((val) => (val != null ? val.toString() : val));
}
return value != null ? value.toString() : value;
};

View file

@ -7,152 +7,39 @@
*/
import { dataViewMock } from '../../__mocks__/data_view';
import { getEuiGridColumns } from './discover_grid_columns';
import { getEuiGridColumns, getVisibleColumns } from './discover_grid_columns';
import { dataViewWithTimefieldMock } from '../../__mocks__/data_view_with_timefield';
import { discoverGridContextMock } from '../../__mocks__/grid_context';
import { discoverServiceMock } from '../../__mocks__/services';
const columns = ['extension', 'message'];
const columnsWithTimeCol = getVisibleColumns(
['extension', 'message'],
dataViewWithTimefieldMock,
true
) as string[];
describe('Discover grid columns', function () {
it('returns eui grid columns without time column', async () => {
const actual = getEuiGridColumns({
columns: ['extension', 'message'],
settings: {},
dataView: dataViewMock,
showTimeCol: false,
defaultColumns: false,
isSortEnabled: true,
isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"displayAsText": "extension",
"id": "extension",
"isSortable": false,
"schema": "string",
describe('getEuiGridColumns', () => {
it('returns eui grid columns showing default columns', async () => {
const actual = getEuiGridColumns({
columns,
settings: {},
dataView: dataViewWithTimefieldMock,
defaultColumns: true,
isSortEnabled: true,
isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
],
"displayAsText": "message",
"id": "message",
"isSortable": false,
"schema": "string",
},
]
`);
});
it('returns eui grid columns without time column showing default columns', async () => {
const actual = getEuiGridColumns({
columns: ['extension', 'message'],
settings: {},
dataView: dataViewWithTimefieldMock,
showTimeCol: false,
defaultColumns: true,
isSortEnabled: true,
isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
@ -246,27 +133,27 @@ describe('Discover grid columns', function () {
},
]
`);
});
it('returns eui grid columns with time column', async () => {
const actual = getEuiGridColumns({
columns: ['extension', 'message'],
settings: {},
dataView: dataViewWithTimefieldMock,
showTimeCol: true,
defaultColumns: false,
isSortEnabled: true,
isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
it('returns eui grid columns with time column', async () => {
const actual = getEuiGridColumns({
columns: columnsWithTimeCol,
settings: {},
dataView: dataViewWithTimefieldMock,
defaultColumns: false,
isSortEnabled: true,
isPlainRecord: false,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
@ -431,191 +318,216 @@ describe('Discover grid columns', function () {
},
]
`);
});
it('returns eui grid with in memory sorting', async () => {
const actual = getEuiGridColumns({
columns: columnsWithTimeCol,
settings: {},
dataView: dataViewWithTimefieldMock,
defaultColumns: false,
isSortEnabled: true,
isPlainRecord: true,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
});
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": false,
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"display": <div
aria-label="timestamp - this field represents the time that events occurred."
>
<EuiToolTip
content="This field represents the time that events occurred."
delay="regular"
display="inlineBlock"
position="top"
>
<React.Fragment>
timestamp
<EuiIcon
type="clock"
/>
</React.Fragment>
</EuiToolTip>
</div>,
"displayAsText": "timestamp",
"id": "timestamp",
"initialWidth": 210,
"isSortable": true,
"schema": "datetime",
},
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"displayAsText": "extension",
"id": "extension",
"isSortable": true,
"schema": "string",
},
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
],
"displayAsText": "message",
"id": "message",
"isSortable": true,
"schema": "string",
},
]
`);
});
});
it('returns eui grid with inmemory sorting', async () => {
const actual = getEuiGridColumns({
columns: ['extension', 'message'],
settings: {},
dataView: dataViewWithTimefieldMock,
showTimeCol: true,
defaultColumns: false,
isSortEnabled: true,
isPlainRecord: true,
valueToStringConverter: discoverGridContextMock.valueToStringConverter,
rowsCount: 100,
services: {
uiSettings: discoverServiceMock.uiSettings,
toastNotifications: discoverServiceMock.toastNotifications,
},
hasEditDataViewPermission: () =>
discoverServiceMock.dataViewFieldEditor.userPermissions.editIndexPattern(),
onFilter: () => {},
describe('getVisibleColumns', () => {
it('returns grid columns without time column when data view has no timestamp field', () => {
const actual = getVisibleColumns(['extension', 'message'], dataViewMock, true) as string[];
expect(actual).toEqual(['extension', 'message']);
});
it('returns grid columns without time column when showTimeCol is falsy', () => {
const actual = getVisibleColumns(
['extension', 'message'],
dataViewWithTimefieldMock,
false
) as string[];
expect(actual).toEqual(['extension', 'message']);
});
it('returns grid columns with time column when data view has timestamp field', () => {
const actual = getVisibleColumns(
['extension', 'message'],
dataViewWithTimefieldMock,
true
) as string[];
expect(actual).toEqual(['timestamp', 'extension', 'message']);
});
expect(actual).toMatchInlineSnapshot(`
Array [
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": false,
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"display": <div
aria-label="timestamp - this field represents the time that events occurred."
>
<EuiToolTip
content="This field represents the time that events occurred."
delay="regular"
display="inlineBlock"
position="top"
>
<React.Fragment>
timestamp
<EuiIcon
type="clock"
/>
</React.Fragment>
</EuiToolTip>
</div>,
"displayAsText": "timestamp",
"id": "timestamp",
"initialWidth": 210,
"isSortable": true,
"schema": "datetime",
},
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
[Function],
[Function],
],
"displayAsText": "extension",
"id": "extension",
"isSortable": true,
"schema": "string",
},
Object {
"actions": Object {
"additional": Array [
Object {
"data-test-subj": "gridCopyColumnNameToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy name"
id="discover.grid.copyColumnNameToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
Object {
"data-test-subj": "gridCopyColumnValuesToClipBoardButton",
"iconProps": Object {
"size": "m",
},
"iconType": "copyClipboard",
"label": <FormattedMessage
defaultMessage="Copy column"
id="discover.grid.copyColumnValuesToClipBoardButton"
values={Object {}}
/>,
"onClick": [Function],
"size": "xs",
},
],
"showHide": Object {
"iconType": "cross",
"label": "Remove column",
},
"showMoveLeft": true,
"showMoveRight": true,
},
"cellActions": Array [
[Function],
],
"displayAsText": "message",
"id": "message",
"isSortable": true,
"schema": "string",
},
]
`);
});
});

View file

@ -8,7 +8,13 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiDataGridColumn, EuiIcon, EuiScreenReaderOnly, EuiToolTip } from '@elastic/eui';
import {
type EuiDataGridColumn,
type EuiDataGridColumnCellAction,
EuiIcon,
EuiScreenReaderOnly,
EuiToolTip,
} from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public';
import { ToastsStart, IUiSettingsClient } from '@kbn/core/public';
import { DocViewFilterFn } from '../../services/doc_views/doc_views_types';
@ -72,6 +78,7 @@ function buildEuiGridColumn({
rowsCount,
onFilter,
editField,
columnCellActions,
}: {
columnName: string;
columnWidth: number | undefined;
@ -85,6 +92,7 @@ function buildEuiGridColumn({
rowsCount: number;
onFilter?: DocViewFilterFn;
editField?: (fieldName: string) => void;
columnCellActions?: EuiDataGridColumnCellAction[];
}) {
const dataViewField = dataView.getFieldByName(columnName);
const editFieldButton =
@ -98,6 +106,13 @@ function buildEuiGridColumn({
})
: dataViewField?.displayName || columnName;
let cellActions: EuiDataGridColumnCellAction[];
if (columnCellActions?.length) {
cellActions = columnCellActions;
} else {
cellActions = dataViewField ? buildCellActions(dataViewField, onFilter) : [];
}
const column: EuiDataGridColumn = {
id: columnName,
schema: getSchemaByKbnType(dataViewField?.type),
@ -134,7 +149,7 @@ function buildEuiGridColumn({
...(editFieldButton ? [editFieldButton] : []),
],
},
cellActions: dataViewField ? buildCellActions(dataViewField, onFilter) : [],
cellActions,
};
if (column.id === dataView.timeFieldName) {
@ -172,10 +187,10 @@ function buildEuiGridColumn({
export function getEuiGridColumns({
columns,
columnsCellActions,
rowsCount,
settings,
dataView,
showTimeCol,
defaultColumns,
isSortEnabled,
isPlainRecord,
@ -186,10 +201,10 @@ export function getEuiGridColumns({
editField,
}: {
columns: string[];
columnsCellActions?: EuiDataGridColumnCellAction[][];
rowsCount: number;
settings: DiscoverGridSettings | undefined;
dataView: DataView;
showTimeCol: boolean;
defaultColumns: boolean;
isSortEnabled: boolean;
isPlainRecord?: boolean;
@ -202,17 +217,12 @@ export function getEuiGridColumns({
onFilter: DocViewFilterFn;
editField?: (fieldName: string) => void;
}) {
const timeFieldName = dataView.timeFieldName;
const getColWidth = (column: string) => settings?.columns?.[column]?.width ?? 0;
let visibleColumns = columns;
if (showTimeCol && dataView.timeFieldName && !columns.find((col) => col === timeFieldName)) {
visibleColumns = [dataView.timeFieldName, ...columns];
}
return visibleColumns.map((column) =>
return columns.map((column, columnIndex) =>
buildEuiGridColumn({
columnName: column,
columnCellActions: columnsCellActions?.[columnIndex],
columnWidth: getColWidth(column),
dataView,
defaultColumns,
@ -231,7 +241,7 @@ export function getEuiGridColumns({
export function getVisibleColumns(columns: string[], dataView: DataView, showTimeCol: boolean) {
const timeFieldName = dataView.timeFieldName;
if (showTimeCol && !columns.find((col) => col === timeFieldName)) {
if (showTimeCol && timeFieldName && !columns.find((col) => col === timeFieldName)) {
return [timeFieldName, ...columns];
}

View file

@ -6,4 +6,16 @@
* Side Public License, v 1.
*/
import type { Trigger } from '@kbn/ui-actions-plugin/public';
export { SEARCH_EMBEDDABLE_TYPE } from '../../common';
export const SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID =
'SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID';
export const SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER: Trigger = {
id: SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID,
title: 'Discover saved searches embeddable cell actions',
description:
'This trigger is used to replace the cell actions for Discover saved search embeddable grid.',
} as const;

View file

@ -6,6 +6,6 @@
* Side Public License, v 1.
*/
export { SEARCH_EMBEDDABLE_TYPE } from './constants';
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants';
export * from './types';
export * from './search_embeddable_factory';

View file

@ -35,12 +35,13 @@ import { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { SavedSearch } from '@kbn/saved-search-plugin/public';
import { METRIC_TYPE } from '@kbn/analytics';
import { CellActionsProvider } from '@kbn/cell-actions';
import { VIEW_MODE } from '../../common/constants';
import { getSortForEmbeddable, SortPair } from '../utils/sorting';
import { buildDataTableRecord } from '../utils/build_data_record';
import { DataTableRecord, EsHitRecord } from '../types';
import { ISearchEmbeddable, SearchInput, SearchOutput } from './types';
import { SEARCH_EMBEDDABLE_TYPE } from './constants';
import { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants';
import { DiscoverServices } from '../build_services';
import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
import {
@ -399,6 +400,7 @@ export class SavedSearchEmbeddable
onUpdateRowsPerPage: (rowsPerPage) => {
this.updateInput({ rowsPerPage });
},
cellActionsTriggerId: SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID,
};
const timeRangeSearchSource = searchSource.create();
@ -565,11 +567,14 @@ export class SavedSearchEmbeddable
query,
};
if (searchProps.services) {
const { getTriggerCompatibleActions } = searchProps.services.uiActions;
ReactDOM.render(
<I18nProvider>
<KibanaThemeProvider theme$={searchProps.services.core.theme.theme$}>
<KibanaContextProvider services={searchProps.services}>
<SavedSearchEmbeddableComponent {...props} />
<CellActionsProvider getTriggerCompatibleActions={getTriggerCompatibleActions}>
<SavedSearchEmbeddableComponent {...props} />
</CellActionsProvider>
</KibanaContextProvider>
</KibanaThemeProvider>
</I18nProvider>,

View file

@ -15,5 +15,5 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export type { ISearchEmbeddable, SearchInput } from './embeddable';
export { SEARCH_EMBEDDABLE_TYPE } from './embeddable';
export { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './embeddable';
export { loadSharingDataHelpers } from './utils';

View file

@ -74,6 +74,7 @@ import {
import { DiscoverAppLocator, DiscoverAppLocatorDefinition } from '../common';
import type { CustomizationCallback } from './customizations';
import { createCustomizeFunction, createProfileRegistry } from './customizations/profile_registry';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER } from './embeddable/constants';
const DocViewerLegacyTable = React.lazy(
() => import('./services/doc_views/components/doc_viewer_table/legacy')
@ -405,6 +406,8 @@ export class DiscoverPlugin
const { uiActions } = plugins;
uiActions.registerTrigger(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER);
const viewSavedSearchAction = new ViewSavedSearchAction(core.application);
uiActions.addTriggerAction('CONTEXT_MENU_TRIGGER', viewSavedSearchAction);
setUiActions(plugins.uiActions);

View file

@ -53,6 +53,7 @@
"@kbn/dom-drag-drop",
"@kbn/unified-field-list",
"@kbn/core-saved-objects-api-server",
"@kbn/cell-actions",
],
"exclude": [
"target/**/*",

View file

@ -171,20 +171,17 @@ describe('DataTable', () => {
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith({
triggerId: 'mockCellActionsTrigger',
data: [
fields: [
{
values: [data[0]?.data[0]?.value],
field: {
name: '@timestamp',
type: 'date',
aggregatable: true,
esTypes: ['date'],
searchable: true,
subType: undefined,
},
name: '@timestamp',
type: 'date',
aggregatable: true,
esTypes: ['date'],
searchable: true,
subType: undefined,
},
],
getCellValue: expect.any(Function),
metadata: {
scopeId: 'table-test',
},
@ -202,7 +199,8 @@ describe('DataTable', () => {
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith(
expect.objectContaining({
data: [],
triggerId: undefined,
fields: undefined,
})
);
});

View file

@ -38,7 +38,10 @@ import {
DeprecatedRowRenderer,
TimelineItem,
} from '@kbn/timelines-plugin/common';
import { useDataGridColumnsCellActions } from '@kbn/cell-actions';
import {
useDataGridColumnsCellActions,
UseDataGridColumnsCellActionsProps,
} from '@kbn/cell-actions';
import { DataTableModel, DataTableState } from '../../store/data_table/types';
import { getColumnHeader, getColumnHeaders } from './column_headers/helpers';
@ -327,36 +330,39 @@ export const DataTableComponent = React.memo<DataTableProps>(
[dispatch, id]
);
const columnsCellActionsProps = useMemo(() => {
const columnsCellActionData = !cellActionsTriggerId
? []
: columnHeaders.map((column) => ({
// TODO use FieldSpec object instead of column
field: {
const cellActionsMetadata = useMemo(() => ({ scopeId: id }), [id]);
const cellActionsFields = useMemo<UseDataGridColumnsCellActionsProps['fields']>(
() =>
cellActionsTriggerId
? // TODO use FieldSpec object instead of column
columnHeaders.map((column) => ({
name: column.id,
type: column.type ?? 'keyword',
aggregatable: column.aggregatable ?? false,
searchable: column.searchable ?? false,
esTypes: column.esTypes ?? [],
subType: column.subType,
},
values: data.map(
({ data: columnData }) =>
columnData.find((rowData) => rowData.field === column.id)?.value
),
}));
}))
: undefined,
[cellActionsTriggerId, columnHeaders]
);
return {
triggerId: cellActionsTriggerId || '',
data: columnsCellActionData,
metadata: {
scopeId: id,
},
dataGridRef,
};
}, [columnHeaders, cellActionsTriggerId, id, data]);
const getCellValue = useCallback<UseDataGridColumnsCellActionsProps['getCellValue']>(
(fieldName, rowIndex) => {
const pageIndex = rowIndex % data.length;
return data[pageIndex].data.find((rowData) => rowData.field === fieldName)?.value;
},
[data]
);
const columnsCellActions = useDataGridColumnsCellActions(columnsCellActionsProps);
const columnsCellActions = useDataGridColumnsCellActions({
triggerId: cellActionsTriggerId,
fields: cellActionsFields,
getCellValue,
metadata: cellActionsMetadata,
dataGridRef,
});
const columnsWithCellActions: EuiDataGridColumn[] = useMemo(
() =>
@ -469,5 +475,3 @@ export const DataTableComponent = React.memo<DataTableProps>(
);
}
);
DataTableComponent.displayName = 'DataTableComponent';

View file

@ -19,8 +19,9 @@
"cloudSecurityPosture",
"dashboard",
"data",
"ecsDataQualityDashboard",
"dataViews",
"discover",
"ecsDataQualityDashboard",
"embeddable",
"eventLog",
"features",
@ -41,7 +42,6 @@
"unifiedSearch",
"files",
"controls",
"dataViews",
"savedObjectsManagement",
"stackConnectors",
],

View file

@ -0,0 +1,130 @@
/*
* 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 type { SecurityAppStore } from '../../../common/store/types';
import { TimelineId } from '../../../../common/types';
import { addProvider } from '../../../timelines/store/timeline/actions';
import { createAddToTimelineDiscoverCellActionFactory } from './add_to_timeline';
import type { CellActionExecutionContext } from '@kbn/cell-actions';
import { GEO_FIELD_TYPE } from '../../../timelines/components/timeline/body/renderers/constants';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { APP_UI_ID } from '../../../../common';
import { BehaviorSubject } from 'rxjs';
const services = createStartServicesMock();
const mockWarningToast = services.notifications.toasts.addWarning;
const currentAppIdSubject$ = new BehaviorSubject<string>(APP_UI_ID);
services.application.currentAppId$ = currentAppIdSubject$.asObservable();
const mockDispatch = jest.fn();
const store = {
dispatch: mockDispatch,
} as unknown as SecurityAppStore;
const value = 'the-value';
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value }],
} as CellActionExecutionContext;
const defaultDataProvider = {
type: addProvider.type,
payload: {
id: TimelineId.active,
providers: [
{
and: [],
enabled: true,
excluded: false,
id: 'event-field-default-timeline-1-user_name-0-the-value',
kqlQuery: '',
name: 'user.name',
queryMatch: {
field: 'user.name',
operator: ':',
value: 'the-value',
},
},
],
},
};
describe('createAddToTimelineDiscoverCellActionFactory', () => {
const addToTimelineDiscoverCellActionFactory = createAddToTimelineDiscoverCellActionFactory({
store,
services,
});
const addToTimelineAction = addToTimelineDiscoverCellActionFactory({
id: 'testAddToTimeline',
order: 1,
});
beforeEach(() => {
currentAppIdSubject$.next(APP_UI_ID);
jest.clearAllMocks();
});
it('should return display name', () => {
expect(addToTimelineAction.getDisplayName(context)).toEqual('Add to timeline');
});
it('should return icon type', () => {
expect(addToTimelineAction.getIconType(context)).toEqual('timeline');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await addToTimelineAction.isCompatible(context)).toEqual(true);
});
it('should return false if not in security', async () => {
currentAppIdSubject$.next('not-security');
expect(await addToTimelineAction.isCompatible(context)).toEqual(false);
});
it('should return false if field not allowed', async () => {
expect(
await addToTimelineAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, name: 'signal.reason' },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
it('should execute normally', async () => {
await addToTimelineAction.execute(context);
expect(mockDispatch).toHaveBeenCalledWith(defaultDataProvider);
expect(mockWarningToast).not.toHaveBeenCalled();
});
it('should show warning if no provider added', async () => {
await addToTimelineAction.execute({
...context,
data: [
{
...context.data[0],
field: {
...context.data[0].field,
type: GEO_FIELD_TYPE,
},
value,
},
],
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import type { SecurityAppStore } from '../../../common/store';
import { isInSecurityApp } from '../../utils';
import type { StartServices } from '../../../types';
import { createAddToTimelineCellActionFactory } from '../cell_action/add_to_timeline';
export const createAddToTimelineDiscoverCellActionFactory = ({
store,
services,
}: {
store: SecurityAppStore;
services: StartServices;
}): CellActionFactory<CellAction> => {
const { application } = services;
let currentAppId: string | undefined;
application.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
const securityAddToTimelineActionFactory = createAddToTimelineCellActionFactory({
store,
services,
});
return securityAddToTimelineActionFactory.combine<CellAction>({
isCompatible: async () => isInSecurityApp(currentAppId),
});
};

View file

@ -8,3 +8,4 @@
export { createAddToTimelineCellActionFactory } from './cell_action/add_to_timeline';
export { createInvestigateInNewTimelineCellActionFactory } from './cell_action/investigate_in_new_timeline';
export { createAddToTimelineLensAction } from './lens/add_to_timeline';
export { createAddToTimelineDiscoverCellActionFactory } from './discover/add_to_timeline';

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createCopyToClipboardCellActionFactory } from './copy_to_clipboard';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import type { CellActionExecutionContext } from '@kbn/cell-actions';
const services = createStartServicesMock();
const mockSuccessToast = services.notifications.toasts.addSuccess;
const mockCopy = jest.fn((text: string) => true);
jest.mock('copy-to-clipboard', () => (text: string) => mockCopy(text));
describe('createCopyToClipboardCellActionFactory', () => {
const copyToClipboardActionFactory = createCopyToClipboardCellActionFactory({ services });
const copyToClipboardAction = copyToClipboardActionFactory({ id: 'testAction' });
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value: 'the value' }],
} as CellActionExecutionContext;
beforeEach(() => {
jest.clearAllMocks();
});
it('should return display name', () => {
expect(copyToClipboardAction.getDisplayName(context)).toEqual('Copy to Clipboard');
});
it('should return icon type', () => {
expect(copyToClipboardAction.getIconType(context)).toEqual('copyClipboard');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await copyToClipboardAction.isCompatible(context)).toEqual(true);
});
it('should return false if field not allowed', async () => {
expect(
await copyToClipboardAction.isCompatible({
...context,
data: [
{
...context.data[0].field,
field: { ...context.data[0].field, name: 'signal.reason' },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
it('should execute normally', async () => {
await copyToClipboardAction.execute(context);
expect(mockCopy).toHaveBeenCalledWith('user.name: "the value"');
expect(mockSuccessToast).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,80 @@
/*
* 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 { createCopyToClipboardDiscoverCellActionFactory } from './copy_to_clipboard';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import type { CellActionExecutionContext } from '@kbn/cell-actions';
import { BehaviorSubject } from 'rxjs';
import { APP_UI_ID } from '../../../../common';
const services = createStartServicesMock();
const mockSuccessToast = services.notifications.toasts.addSuccess;
const currentAppIdSubject$ = new BehaviorSubject<string>(APP_UI_ID);
services.application.currentAppId$ = currentAppIdSubject$.asObservable();
const mockCopy = jest.fn((text: string) => true);
jest.mock('copy-to-clipboard', () => (text: string) => mockCopy(text));
describe('createCopyToClipboardDiscoverCellActionFactory', () => {
const copyToClipboardActionFactory = createCopyToClipboardDiscoverCellActionFactory({ services });
const copyToClipboardAction = copyToClipboardActionFactory({ id: 'testAction' });
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
value: 'the value',
},
],
} as CellActionExecutionContext;
beforeEach(() => {
currentAppIdSubject$.next(APP_UI_ID);
jest.clearAllMocks();
});
it('should return display name', () => {
expect(copyToClipboardAction.getDisplayName(context)).toEqual('Copy to Clipboard');
});
it('should return icon type', () => {
expect(copyToClipboardAction.getIconType(context)).toEqual('copyClipboard');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await copyToClipboardAction.isCompatible(context)).toEqual(true);
});
it('should return false if not in security', async () => {
currentAppIdSubject$.next('not-security');
expect(await copyToClipboardAction.isCompatible(context)).toEqual(false);
});
it('should return false if field not allowed', async () => {
expect(
await copyToClipboardAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, name: 'signal.reason' },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
it('should execute normally', async () => {
await copyToClipboardAction.execute(context);
expect(mockCopy).toHaveBeenCalledWith('user.name: "the value"');
expect(mockSuccessToast).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../utils';
import type { StartServices } from '../../../types';
import { createCopyToClipboardCellActionFactory } from '../cell_action/copy_to_clipboard';
export const createCopyToClipboardDiscoverCellActionFactory = ({
services,
}: {
services: StartServices;
}): CellActionFactory<CellAction> => {
const { application } = services;
let currentAppId: string | undefined;
application.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
const genericCopyToClipboardActionFactory = createCopyToClipboardCellActionFactory({
services,
});
return genericCopyToClipboardActionFactory.combine<CellAction>({
isCompatible: async () => isInSecurityApp(currentAppId),
});
};

View file

@ -7,3 +7,4 @@
export { createCopyToClipboardLensAction } from './lens/copy_to_clipboard';
export { createCopyToClipboardCellActionFactory } from './cell_action/copy_to_clipboard';
export { createCopyToClipboardDiscoverCellActionFactory } from './discover/copy_to_clipboard';

View file

@ -20,7 +20,8 @@ import { TableId } from '@kbn/securitysolution-data-table';
import { TimelineId } from '../../../../common/types';
const services = createStartServicesMock();
const mockFilterManager = services.data.query.filterManager;
const mockGlobalFilterManager = services.data.query.filterManager;
const mockTimelineFilterManager = createFilterManagerMock();
const mockState = {
...mockGlobalState,
@ -30,7 +31,7 @@ const mockState = {
...mockGlobalState.timeline.timelineById,
[TimelineId.active]: {
...mockGlobalState.timeline.timelineById[TimelineId.active],
filterManager: createFilterManagerMock(),
filterManager: mockTimelineFilterManager,
},
},
},
@ -88,56 +89,64 @@ describe('createFilterInCellActionFactory', () => {
});
});
describe('generic scope execution', () => {
const dataTableContext = {
...context,
metadata: { scopeId: TableId.alertsOnAlertsPage },
} as SecurityCellActionExecutionContext;
describe('execute', () => {
describe('generic scope execution', () => {
const dataTableContext = {
...context,
metadata: { scopeId: TableId.alertsOnAlertsPage },
} as SecurityCellActionExecutionContext;
it('should execute using generic filterManager', async () => {
await filterInAction.execute(dataTableContext);
expect(mockFilterManager.addFilters).toHaveBeenCalled();
expect(
mockState.timeline.timelineById[TimelineId.active].filterManager?.addFilters
).not.toHaveBeenCalled();
});
});
describe('timeline scope execution', () => {
const timelineContext = {
...context,
metadata: { scopeId: TimelineId.active },
} as SecurityCellActionExecutionContext;
it('should execute using timeline filterManager', async () => {
await filterInAction.execute(timelineContext);
expect(
mockState.timeline.timelineById[TimelineId.active].filterManager?.addFilters
).toHaveBeenCalled();
expect(mockFilterManager.addFilters).not.toHaveBeenCalled();
it('should execute using generic filterManager', async () => {
await filterInAction.execute(dataTableContext);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
});
});
describe('should execute correctly when negateFilters is provided', () => {
it('if negateFilters is false, negate should be false (do not exclude)', async () => {
describe('timeline scope execution', () => {
const timelineContext = {
...context,
metadata: { scopeId: TimelineId.active },
} as SecurityCellActionExecutionContext;
it('should execute using timeline filterManager', async () => {
await filterInAction.execute(timelineContext);
expect(mockTimelineFilterManager.addFilters).toHaveBeenCalled();
expect(mockGlobalFilterManager.addFilters).not.toHaveBeenCalled();
});
});
describe('negateFilters', () => {
it('if negateFilters is false, negate should be false (include)', async () => {
await filterInAction.execute({
...context,
metadata: {
negateFilters: false,
},
});
expect(mockFilterManager.addFilters).toHaveBeenCalled();
// expect(mockCreateFilter).toBeCalledWith(context.field.name, context.field.value, false);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({
negate: false,
}),
})
);
});
it('if negateFilters is true, negate should be true (exclude)', async () => {
it('if negateFilters is true, negate should be true (do not include)', async () => {
await filterInAction.execute({
...context,
metadata: {
negateFilters: true,
},
});
expect(mockFilterManager.addFilters).toHaveBeenCalled();
// expect(mockCreateFilter).toBeCalledWith(context.field.name, context.field.value, true);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({
negate: true,
}),
})
);
});
});
});

View file

@ -20,7 +20,8 @@ import { TimelineId } from '../../../../common/types';
import { TableId } from '@kbn/securitysolution-data-table';
const services = createStartServicesMock();
const mockFilterManager = services.data.query.filterManager;
const mockGlobalFilterManager = services.data.query.filterManager;
const mockTimelineFilterManager = createFilterManagerMock();
const mockState = {
...mockGlobalState,
@ -30,7 +31,7 @@ const mockState = {
...mockGlobalState.timeline.timelineById,
[TimelineId.active]: {
...mockGlobalState.timeline.timelineById[TimelineId.active],
filterManager: createFilterManagerMock(),
filterManager: mockTimelineFilterManager,
},
},
},
@ -82,33 +83,65 @@ describe('createFilterOutCellActionFactory', () => {
});
});
describe('generic scope execution', () => {
const dataTableContext = {
...context,
metadata: { scopeId: TableId.alertsOnAlertsPage },
} as SecurityCellActionExecutionContext;
describe('execute', () => {
describe('generic scope execution', () => {
const dataTableContext = {
...context,
metadata: { scopeId: TableId.alertsOnAlertsPage },
} as SecurityCellActionExecutionContext;
it('should execute using generic filterManager', async () => {
await filterOutAction.execute(dataTableContext);
expect(mockFilterManager.addFilters).toHaveBeenCalled();
expect(
mockState.timeline.timelineById[TimelineId.active].filterManager?.addFilters
).not.toHaveBeenCalled();
it('should execute using generic filterManager', async () => {
await filterOutAction.execute(dataTableContext);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
});
});
});
describe('timeline scope execution', () => {
const timelineContext = {
...context,
metadata: { scopeId: TimelineId.active },
} as SecurityCellActionExecutionContext;
describe('timeline scope execution', () => {
const timelineContext = {
...context,
metadata: { scopeId: TimelineId.active },
} as SecurityCellActionExecutionContext;
it('should execute using timeline filterManager', async () => {
await filterOutAction.execute(timelineContext);
expect(
mockState.timeline.timelineById[TimelineId.active].filterManager?.addFilters
).toHaveBeenCalled();
expect(mockFilterManager.addFilters).not.toHaveBeenCalled();
it('should execute using timeline filterManager', async () => {
await filterOutAction.execute(timelineContext);
expect(mockTimelineFilterManager.addFilters).toHaveBeenCalled();
expect(mockGlobalFilterManager.addFilters).not.toHaveBeenCalled();
});
});
describe('negateFilters', () => {
it('if negateFilters is false, negate should be true (exclude)', async () => {
await filterOutAction.execute({
...context,
metadata: {
negateFilters: false,
},
});
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({
negate: true,
}),
})
);
});
it('if negateFilters is true, negate should be false (do not exclude)', async () => {
await filterOutAction.execute({
...context,
metadata: {
negateFilters: true,
},
});
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalledWith(
expect.objectContaining({
meta: expect.objectContaining({
negate: false,
}),
})
);
});
});
});
});

View file

@ -0,0 +1,88 @@
/*
* 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 {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
} from '../../../common/mock';
import { createStore } from '../../../common/store';
import { createFilterInDiscoverCellActionFactory } from './filter_in';
import type { SecurityCellActionExecutionContext } from '../../types';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { BehaviorSubject } from 'rxjs';
import { APP_UI_ID } from '../../../../common';
const services = createStartServicesMock();
const mockGlobalFilterManager = services.data.query.filterManager;
const currentAppIdSubject$ = new BehaviorSubject<string>(APP_UI_ID);
services.application.currentAppId$ = currentAppIdSubject$.asObservable();
jest.mock('@kbn/ui-actions-plugin/public', () => ({
...jest.requireActual('@kbn/ui-actions-plugin/public'),
addFilterIn: () => {},
addFilterOut: () => {},
}));
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
describe('createFilterInDiscoverCellActionFactory', () => {
const createFilterInCellAction = createFilterInDiscoverCellActionFactory({
store: mockStore,
services,
});
const filterInAction = createFilterInCellAction({ id: 'testAction' });
beforeEach(() => {
currentAppIdSubject$.next(APP_UI_ID);
jest.clearAllMocks();
});
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value: 'the value' }],
} as SecurityCellActionExecutionContext;
it('should return display name', () => {
expect(filterInAction.getDisplayName(context)).toEqual('Filter In');
});
it('should return icon type', () => {
expect(filterInAction.getIconType(context)).toEqual('plusInCircle');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await filterInAction.isCompatible(context)).toEqual(true);
});
it('should return false if not in security', async () => {
currentAppIdSubject$.next('not-security');
expect(await filterInAction.isCompatible(context)).toEqual(false);
});
it('should return false if field not allowed', async () => {
expect(
await filterInAction.isCompatible({
...context,
data: [
{ ...context.data[0], field: { ...context.data[0].field, name: 'signal.reason' } },
],
})
).toEqual(false);
});
});
describe('execution', () => {
it('should execute using generic filterManager', async () => {
await filterInAction.execute(context);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 type { CellAction, CellActionFactory } from '@kbn/cell-actions';
import type { SecurityAppStore } from '../../../common/store';
import { isInSecurityApp } from '../../utils';
import type { StartServices } from '../../../types';
import { createFilterInCellActionFactory } from '../cell_action/filter_in';
export const createFilterInDiscoverCellActionFactory = ({
store,
services,
}: {
store: SecurityAppStore;
services: StartServices;
}): CellActionFactory<CellAction> => {
const { application } = services;
let currentAppId: string | undefined;
application.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
const securityFilterInActionFactory = createFilterInCellActionFactory({ store, services });
return securityFilterInActionFactory.combine<CellAction>({
isCompatible: async () => isInSecurityApp(currentAppId),
});
};

View file

@ -0,0 +1,93 @@
/*
* 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 {
createSecuritySolutionStorageMock,
kibanaObservable,
mockGlobalState,
SUB_PLUGINS_REDUCER,
} from '../../../common/mock';
import { createStore } from '../../../common/store';
import { createFilterOutDiscoverCellActionFactory } from './filter_out';
import type { SecurityCellActionExecutionContext } from '../../types';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { BehaviorSubject } from 'rxjs';
import { APP_UI_ID } from '../../../../common';
const services = createStartServicesMock();
const mockGlobalFilterManager = services.data.query.filterManager;
const currentAppIdSubject$ = new BehaviorSubject<string>(APP_UI_ID);
services.application.currentAppId$ = currentAppIdSubject$.asObservable();
jest.mock('@kbn/ui-actions-plugin/public', () => ({
...jest.requireActual('@kbn/ui-actions-plugin/public'),
addFilterIn: () => {},
addFilterOut: () => {},
}));
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
describe('createFilterOutDiscoverCellActionFactory', () => {
const createFilterOutCellAction = createFilterOutDiscoverCellActionFactory({
store: mockStore,
services,
});
const filterOutAction = createFilterOutCellAction({ id: 'testAction' });
beforeEach(() => {
currentAppIdSubject$.next(APP_UI_ID);
jest.clearAllMocks();
});
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
value: 'the value',
},
],
} as SecurityCellActionExecutionContext;
it('should return display name', () => {
expect(filterOutAction.getDisplayName(context)).toEqual('Filter Out');
});
it('should return icon type', () => {
expect(filterOutAction.getIconType(context)).toEqual('minusInCircle');
});
describe('isCompatible', () => {
it('should return true if everything is okay', async () => {
expect(await filterOutAction.isCompatible(context)).toEqual(true);
});
it('should return false if not in security', async () => {
currentAppIdSubject$.next('not-security');
expect(await filterOutAction.isCompatible(context)).toEqual(false);
});
it('should return false if field not allowed', async () => {
expect(
await filterOutAction.isCompatible({
...context,
data: [
{ ...context.data[0], field: { ...context.data[0].field, name: 'signal.reason' } },
],
})
).toEqual(false);
});
});
describe('execution', () => {
it('should execute using generic filterManager', async () => {
await filterOutAction.execute(context);
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,33 @@
/*
* 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 type { CellActionFactory, CellAction } from '@kbn/cell-actions';
import { isInSecurityApp } from '../../utils';
import type { SecurityAppStore } from '../../../common/store';
import type { StartServices } from '../../../types';
import { createFilterOutCellActionFactory } from '../cell_action/filter_out';
export const createFilterOutDiscoverCellActionFactory = ({
store,
services,
}: {
store: SecurityAppStore;
services: StartServices;
}): CellActionFactory<CellAction> => {
const { application } = services;
let currentAppId: string | undefined;
application.currentAppId$.subscribe((appId) => {
currentAppId = appId;
});
const genericFilterOutActionFactory = createFilterOutCellActionFactory({ store, services });
return genericFilterOutActionFactory.combine<CellAction>({
isCompatible: async () => isInSecurityApp(currentAppId),
});
};

View file

@ -7,3 +7,5 @@
export { createFilterInCellActionFactory } from './cell_action/filter_in';
export { createFilterOutCellActionFactory } from './cell_action/filter_out';
export { createFilterInDiscoverCellActionFactory } from './discover/filter_in';
export { createFilterOutDiscoverCellActionFactory } from './discover/filter_out';

View file

@ -6,35 +6,49 @@
*/
import { CELL_VALUE_TRIGGER } from '@kbn/embeddable-plugin/public';
import type * as H from 'history';
import type { History } from 'history';
import { SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from '@kbn/discover-plugin/public';
import type { SecurityAppStore } from '../common/store/types';
import type { StartServices } from '../types';
import { createFilterInCellActionFactory, createFilterOutCellActionFactory } from './filter';
import {
createFilterInCellActionFactory,
createFilterInDiscoverCellActionFactory,
createFilterOutCellActionFactory,
createFilterOutDiscoverCellActionFactory,
} from './filter';
import {
createAddToTimelineLensAction,
createAddToTimelineCellActionFactory,
createInvestigateInNewTimelineCellActionFactory,
createAddToTimelineDiscoverCellActionFactory,
} from './add_to_timeline';
import { createShowTopNCellActionFactory } from './show_top_n';
import {
createCopyToClipboardLensAction,
createCopyToClipboardCellActionFactory,
createCopyToClipboardDiscoverCellActionFactory,
} from './copy_to_clipboard';
import { createToggleColumnCellActionFactory } from './toggle_column';
import { SecurityCellActionsTrigger } from './constants';
import type { SecurityCellActionName, SecurityCellActions } from './types';
import type {
DiscoverCellActionName,
DiscoverCellActions,
SecurityCellActionName,
SecurityCellActions,
} from './types';
import { enhanceActionWithTelemetry } from './telemetry';
export const registerUIActions = (
store: SecurityAppStore,
history: H.History,
history: History,
services: StartServices
) => {
registerLensActions(store, services);
registerLensEmbeddableActions(store, services);
registerDiscoverCellActions(store, services);
registerCellActions(store, history, services);
};
const registerLensActions = (store: SecurityAppStore, services: StartServices) => {
const registerLensEmbeddableActions = (store: SecurityAppStore, services: StartServices) => {
const { uiActions } = services;
const addToTimelineAction = createAddToTimelineLensAction({ store, order: 1 });
@ -44,12 +58,47 @@ const registerLensActions = (store: SecurityAppStore, services: StartServices) =
uiActions.addTriggerAction(CELL_VALUE_TRIGGER, copyToClipboardAction);
};
const registerDiscoverCellActions = (store: SecurityAppStore, services: StartServices) => {
const { uiActions } = services;
const DiscoverCellActionsFactories: DiscoverCellActions = {
filterIn: createFilterInDiscoverCellActionFactory({ store, services }),
filterOut: createFilterOutDiscoverCellActionFactory({ store, services }),
addToTimeline: createAddToTimelineDiscoverCellActionFactory({ store, services }),
copyToClipboard: createCopyToClipboardDiscoverCellActionFactory({ services }),
};
const addDiscoverEmbeddableCellActions = (
triggerId: string,
actionsOrder: DiscoverCellActionName[]
) => {
actionsOrder.forEach((actionName, order) => {
const actionFactory = DiscoverCellActionsFactories[actionName];
if (actionFactory) {
const action = actionFactory({ id: `${triggerId}-${actionName}`, order });
const actionWithTelemetry = enhanceActionWithTelemetry(action, services);
uiActions.addTriggerAction(triggerId, actionWithTelemetry);
}
});
};
// this trigger is already registered by discover search embeddable
addDiscoverEmbeddableCellActions(SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID, [
'filterIn',
'filterOut',
'addToTimeline',
'copyToClipboard',
]);
};
const registerCellActions = (
store: SecurityAppStore,
history: H.History,
history: History,
services: StartServices
) => {
const cellActions: SecurityCellActions = {
const { uiActions } = services;
const cellActionsFactories: SecurityCellActions = {
filterIn: createFilterInCellActionFactory({ store, services }),
filterOut: createFilterOutCellActionFactory({ store, services }),
addToTimeline: createAddToTimelineCellActionFactory({ store, services }),
@ -59,55 +108,37 @@ const registerCellActions = (
toggleColumn: createToggleColumnCellActionFactory({ store }),
};
registerCellActionsTrigger({
triggerId: SecurityCellActionsTrigger.DEFAULT,
cellActions,
actionsOrder: ['filterIn', 'filterOut', 'addToTimeline', 'showTopN', 'copyToClipboard'],
services,
});
const registerCellActionsTrigger = (
triggerId: SecurityCellActionsTrigger,
actionsOrder: SecurityCellActionName[]
) => {
uiActions.registerTrigger({ id: triggerId });
actionsOrder.forEach((actionName, order) => {
const actionFactory = cellActionsFactories[actionName];
if (actionFactory) {
const action = actionFactory({ id: `${triggerId}-${actionName}`, order });
const actionWithTelemetry = enhanceActionWithTelemetry(action, services);
uiActions.addTriggerAction(triggerId, actionWithTelemetry);
}
});
};
registerCellActionsTrigger({
triggerId: SecurityCellActionsTrigger.DETAILS_FLYOUT,
cellActions,
actionsOrder: [
'filterIn',
'filterOut',
'addToTimeline',
'toggleColumn',
'showTopN',
'copyToClipboard',
],
services,
});
registerCellActionsTrigger(SecurityCellActionsTrigger.DEFAULT, [
'filterIn',
'filterOut',
'addToTimeline',
'showTopN',
'copyToClipboard',
]);
registerCellActionsTrigger({
triggerId: SecurityCellActionsTrigger.ALERTS_COUNT,
cellActions,
actionsOrder: ['investigateInNewTimeline'],
services,
});
};
const registerCellActionsTrigger = ({
triggerId,
cellActions,
actionsOrder,
services,
}: {
triggerId: SecurityCellActionsTrigger;
cellActions: SecurityCellActions;
actionsOrder: SecurityCellActionName[];
services: StartServices;
}) => {
const { uiActions } = services;
uiActions.registerTrigger({ id: triggerId });
actionsOrder.forEach((actionName, order) => {
const actionFactory = cellActions[actionName];
if (actionFactory) {
const action = actionFactory({ id: `${triggerId}-${actionName}`, order });
uiActions.addTriggerAction(triggerId, enhanceActionWithTelemetry(action, services));
}
});
registerCellActionsTrigger(SecurityCellActionsTrigger.DETAILS_FLYOUT, [
'filterIn',
'filterOut',
'addToTimeline',
'toggleColumn',
'showTopN',
'copyToClipboard',
]);
registerCellActionsTrigger(SecurityCellActionsTrigger.ALERTS_COUNT, ['investigateInNewTimeline']);
};

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import ReactDOM, { unmountComponentAtNode } from 'react-dom';
import type * as H from 'history';
import type { History } from 'history';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
@ -38,7 +38,7 @@ export const createShowTopNCellActionFactory = createCellActionFactory(
services,
}: {
store: SecurityAppStore;
history: H.History;
history: History;
services: StartServices;
}): CellActionTemplate<SecurityCellAction> => ({
type: SecurityCellActionType.SHOW_TOP_N,

View file

@ -54,14 +54,24 @@ export interface SecurityCellActionExecutionContext extends CellActionExecutionC
export type SecurityCellAction = CellAction<SecurityCellActionExecutionContext>;
export interface SecurityCellActions {
filterIn?: CellActionFactory;
filterOut?: CellActionFactory;
addToTimeline?: CellActionFactory;
investigateInNewTimeline?: CellActionFactory;
showTopN?: CellActionFactory;
copyToClipboard?: CellActionFactory;
toggleColumn?: CellActionFactory;
filterIn: CellActionFactory;
filterOut: CellActionFactory;
addToTimeline: CellActionFactory;
investigateInNewTimeline: CellActionFactory;
showTopN: CellActionFactory;
copyToClipboard: CellActionFactory;
toggleColumn: CellActionFactory;
}
// All security cell actions names
export type SecurityCellActionName = keyof SecurityCellActions;
export interface DiscoverCellActions {
filterIn: CellActionFactory;
filterOut: CellActionFactory;
addToTimeline: CellActionFactory;
copyToClipboard: CellActionFactory;
}
// All Discover search embeddable cell actions names
export type DiscoverCellActionName = keyof DiscoverCellActions;

View file

@ -61,40 +61,41 @@ export const getUseCellActionsHook = (tableId: TableId) => {
useShallowEqualSelector((state) => (getTable(state, tableId) ?? tableDefaults).viewMode) ??
tableDefaults.viewMode;
const cellActionProps = useMemo<UseDataGridColumnsSecurityCellActionsProps>(() => {
const cellActionsData =
viewMode === VIEW_SELECTION.eventRenderedView
? []
: columns.map((col) => {
const fieldMeta: Partial<BrowserField> | undefined = browserFieldsByName[col.id];
return {
// TODO use FieldSpec object instead of browserField
field: {
name: col.id,
type: fieldMeta?.type ?? 'keyword',
esTypes: fieldMeta?.esTypes ?? [],
aggregatable: fieldMeta?.aggregatable ?? false,
searchable: fieldMeta?.searchable ?? false,
subType: fieldMeta?.subType,
},
values: (finalData as TimelineNonEcsData[][]).map(
(row) => row.find((rowData) => rowData.field === col.id)?.value ?? []
),
};
});
const cellActionsMetadata = useMemo(() => ({ scopeId: tableId }), []);
return {
triggerId: SecurityCellActionsTrigger.DEFAULT,
data: cellActionsData,
metadata: {
// cell actions scope
scopeId: tableId,
},
dataGridRef,
};
}, [viewMode, browserFieldsByName, columns, finalData, dataGridRef]);
const cellActionsFields = useMemo<UseDataGridColumnsSecurityCellActionsProps['fields']>(() => {
if (viewMode === VIEW_SELECTION.eventRenderedView) {
return undefined;
}
return columns.map((column) => {
// TODO use FieldSpec object instead of browserField
const browserField: Partial<BrowserField> | undefined = browserFieldsByName[column.id];
return {
name: column.id,
type: browserField?.type ?? 'keyword',
esTypes: browserField?.esTypes ?? [],
aggregatable: browserField?.aggregatable ?? false,
searchable: browserField?.searchable ?? false,
subType: browserField?.subType,
};
});
}, [browserFieldsByName, columns, viewMode]);
const cellActions = useDataGridColumnsSecurityCellActions(cellActionProps);
const getCellValue = useCallback<UseDataGridColumnsSecurityCellActionsProps['getCellValue']>(
(fieldName, rowIndex) => {
const pageRowIndex = rowIndex % finalData.length;
return finalData[pageRowIndex].find((rowData) => rowData.field === fieldName)?.value ?? [];
},
[finalData]
);
const cellActions = useDataGridColumnsSecurityCellActions({
triggerId: SecurityCellActionsTrigger.DEFAULT,
fields: cellActionsFields,
getCellValue,
metadata: cellActionsMetadata,
dataGridRef,
});
const getCellActions = useCallback(
(_columnId: string, columnIndex: number) => {

View file

@ -159,6 +159,7 @@
"@kbn/ecs",
"@kbn/url-state",
"@kbn/ml-anomaly-utils",
"@kbn/discover-plugin",
"@kbn/field-formats-plugin",
"@kbn/dev-proc-runner",
"@kbn/cloud-chat-plugin"