[SecuritySolutions] Update CellActions to support all types used by Discover (#160524)

Original issue: https://github.com/elastic/kibana/issues/144943

## Summary

* Update CellActions value to be `Serializable`.
* Update Default Actions and SecuritySolution Actions to allowlist the
supported Kibana types.
* Add an extra check to Action's `execute` to ensure the field value is
compatible.

### How to test it?
* Open Discover and create a saved search with many different field
types
* Go to Security Solutions dashboards
* Create a new dashboard and import the saved search
* Test the created dashboard inside Security Solutions


### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2023-06-30 15:50:39 +02:00 committed by GitHub
parent e7e1932854
commit 360c4c30fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 633 additions and 233 deletions

View file

@ -9,21 +9,25 @@
import { createCopyToClipboardActionFactory } from './copy_to_clipboard';
import type { CellActionExecutionContext } from '../../types';
import type { NotificationsStart } from '@kbn/core/public';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
const mockSuccessToast = jest.fn();
const mockWarningToast = jest.fn();
const mockCopy = jest.fn((text: string) => true);
jest.mock('copy-to-clipboard', () => (text: string) => mockCopy(text));
describe('Default createCopyToClipboardActionFactory', () => {
const copyToClipboardActionFactory = createCopyToClipboardActionFactory({
notifications: { toasts: { addSuccess: mockSuccessToast } } as unknown as NotificationsStart,
notifications: {
toasts: { addSuccess: mockSuccessToast, addWarning: mockWarningToast },
} as unknown as NotificationsStart,
});
const copyToClipboardAction = copyToClipboardActionFactory({ id: 'testAction' });
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value: 'the value',
},
],
@ -45,6 +49,20 @@ describe('Default createCopyToClipboardActionFactory', () => {
it('should return true if everything is okay', async () => {
expect(await copyToClipboardAction.isCompatible(context)).toEqual(true);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await copyToClipboardAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.NUMBER_RANGE },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -111,5 +129,19 @@ describe('Default createCopyToClipboardActionFactory', () => {
expect(mockCopy).toHaveBeenCalledWith('user.name: true AND false AND true');
expect(mockSuccessToast).toHaveBeenCalled();
});
it('should notify the user when value type is unsupported', async () => {
await copyToClipboardAction.execute({
...context,
data: [
{
...context.data[0],
value: {},
},
],
});
expect(mockCopy).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
});

View file

@ -10,8 +10,16 @@ import copy from 'copy-to-clipboard';
import { i18n } from '@kbn/i18n';
import type { NotificationsStart } from '@kbn/core/public';
import { isString } from 'lodash/fp';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { COPY_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
import {
filterOutNullableValues,
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
} from '../utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '../translations';
const ICON = 'copyClipboard';
const COPY_TO_CLIPBOARD = i18n.translate('cellActions.actions.copyToClipboard.displayName', {
@ -37,19 +45,24 @@ export const createCopyToClipboardActionFactory = createCellActionFactory(
return (
data.length === 1 && // TODO Add support for multiple values
field.name != null
field.name != null &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data }) => {
const field = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
let textValue: undefined | string;
if (value != null) {
const valuesArray = Array.isArray(value) ? value : [value];
textValue = valuesArray.map((v) => (isString(v) ? `"${escapeValue(v)}"` : v)).join(' AND ');
if (!isValueSupportedByDefaultActions(value)) {
notifications.toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
return;
}
const text = textValue ? `${field.name}: ${textValue}` : field.name;
const textValue = value.map((v) => (isString(v) ? `"${escapeValue(v)}"` : v)).join(' AND ');
const text = textValue !== '' ? `${field.name}: ${textValue}` : field.name;
const isSuccess = copy(text, { debug: true });
if (isSuccess) {

View file

@ -15,11 +15,8 @@ const booleanValue = true;
describe('createFilter', () => {
it.each([
{ caseName: 'string', caseValue: value },
{ caseName: 'string array', caseValue: [value] },
{ caseName: 'number', caseValue: numberValue, query: numberValue.toString() },
{ caseName: 'number array', caseValue: [numberValue], query: numberValue.toString() },
{ caseName: 'boolean', caseValue: booleanValue, query: booleanValue.toString() },
{ caseName: 'boolean array', caseValue: [booleanValue], query: booleanValue.toString() },
])('should return filter with $caseName value', ({ caseValue, query = value }) => {
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
@ -42,11 +39,8 @@ describe('createFilter', () => {
});
it.each([
{ caseName: 'string', caseValue: value },
{ caseName: 'string array', caseValue: [value] },
{ caseName: 'number', caseValue: numberValue, query: numberValue.toString() },
{ caseName: 'number array', caseValue: [numberValue], query: numberValue.toString() },
{ caseName: 'boolean', caseValue: booleanValue, query: booleanValue.toString() },
{ caseName: 'boolean array', caseValue: [booleanValue], query: booleanValue.toString() },
])('should return negate filter with $caseName value', ({ caseValue, query = value }) => {
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
@ -93,45 +87,41 @@ describe('createFilter', () => {
});
});
it.each([
{ caseName: 'null', caseValue: null },
{ caseName: 'undefined', caseValue: undefined },
{ caseName: 'empty string', caseValue: '' },
{ caseName: 'empty array', caseValue: [] },
])('should return exist filter with $caseName value', ({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
query: {
exists: {
field,
it.each([{ caseName: 'empty array', caseValue: [] }])(
'should return exist filter with $caseName value',
({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
query: {
exists: {
field,
},
},
},
meta: {
key: field,
negate: false,
type: 'exists',
value: 'exists',
},
});
});
meta: {
key: field,
negate: false,
type: 'exists',
value: 'exists',
},
});
}
);
it.each([
{ caseName: 'null', caseValue: null },
{ caseName: 'undefined', caseValue: undefined },
{ caseName: 'empty string', caseValue: '' },
{ caseName: 'empty array', caseValue: [] },
])('should return negate exist filter with $caseName value', ({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
query: {
exists: {
field,
it.each([{ caseName: 'empty array', caseValue: [] }])(
'should return negate exist filter with $caseName value',
({ caseValue }) => {
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
query: {
exists: {
field,
},
},
},
meta: {
key: field,
negate: true,
type: 'exists',
value: 'exists',
},
});
});
meta: {
key: field,
negate: true,
type: 'exists',
value: 'exists',
},
});
}
);
});

View file

@ -13,13 +13,10 @@ import {
type PhraseFilter,
type Filter,
} from '@kbn/es-query';
import { isArray } from 'lodash/fp';
import { CellActionFieldValue } from '../../types';
import { DefaultActionsSupportedValue } from '../types';
export const isEmptyFilterValue = (
value: CellActionFieldValue
): value is null | undefined | never[] =>
value == null || value === '' || (isArray(value) && value.length === 0);
export const isEmptyFilterValue = (value: Array<string | number | boolean>) =>
value.length === 0 || value.every((v) => v === '');
const createExistsFilter = ({ key, negate }: { key: string; negate: boolean }): ExistsFilter => ({
meta: { key, negate, type: FILTERS.EXISTS, value: 'exists' },
@ -49,7 +46,7 @@ const createCombinedFilter = ({
key,
negate,
}: {
values: string[] | number[] | boolean[];
values: DefaultActionsSupportedValue;
key: string;
negate: boolean;
}): CombinedFilter => ({
@ -68,18 +65,16 @@ export const createFilter = ({
negate,
}: {
key: string;
value: CellActionFieldValue;
value: DefaultActionsSupportedValue;
negate: boolean;
}): Filter => {
if (isEmptyFilterValue(value)) {
if (value.length === 0) {
return createExistsFilter({ key, negate });
}
if (Array.isArray(value)) {
if (value.length > 1) {
return createCombinedFilter({ key, negate, values: value });
} else {
return createPhraseFilter({ key, negate, value: value[0] });
}
if (value.length > 1) {
return createCombinedFilter({ key, negate, values: value });
} else {
return createPhraseFilter({ key, negate, value: value[0] });
}
return createPhraseFilter({ key, negate, value });
};

View file

@ -5,9 +5,10 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { FilterManager } from '@kbn/data-plugin/public';
import { FilterManager, KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { createFilterInActionFactory } from './filter_in';
import { makeActionContext } from '../../mocks/helpers';
import { NotificationsStart } from '@kbn/core-notifications-browser';
const mockFilterManager = { addFilters: jest.fn() } as unknown as FilterManager;
@ -20,15 +21,18 @@ jest.mock('./create_filter', () => ({
const fieldName = 'user.name';
const value = 'the value';
const mockWarningToast = jest.fn();
describe('createFilterInActionFactory', () => {
const filterInActionFactory = createFilterInActionFactory({
filterManager: mockFilterManager,
notifications: { toasts: { addWarning: mockWarningToast } } as unknown as NotificationsStart,
});
const filterInAction = filterInActionFactory({ id: 'testAction' });
const context = makeActionContext({
data: [
{
field: { name: fieldName, type: 'text', searchable: true, aggregatable: true },
field: { name: fieldName, type: 'string', searchable: true, aggregatable: true },
value,
},
],
@ -57,12 +61,27 @@ describe('createFilterInActionFactory', () => {
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, name: '' },
},
],
})
).toEqual(false);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await filterInAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.MISSING },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -75,7 +94,7 @@ describe('createFilterInActionFactory', () => {
await filterInAction.execute(context);
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value,
value: [value],
negate: false,
});
});
@ -107,7 +126,7 @@ describe('createFilterInActionFactory', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: null, negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: true });
});
it('should create negate filter query with undefined value', async () => {
@ -122,7 +141,7 @@ describe('createFilterInActionFactory', () => {
});
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: undefined,
value: [],
negate: true,
});
});
@ -137,7 +156,7 @@ describe('createFilterInActionFactory', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: '', negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [''], negate: true });
});
it('should create negate filter query with empty array value', async () => {
@ -152,5 +171,19 @@ describe('createFilterInActionFactory', () => {
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: true });
});
it('should notify the user when value type is unsupported', async () => {
await filterInAction.execute({
...context,
data: [
{
...context.data[0],
value: [{}, {}, {}],
},
],
});
expect(mockCreateFilter).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
});

View file

@ -6,11 +6,19 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { FilterManager } from '@kbn/data-plugin/public';
import type { FilterManager, KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { NotificationsStart } from '@kbn/core-notifications-browser';
import { createFilter, isEmptyFilterValue } from './create_filter';
import { FILTER_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
import { CellActionFieldValue } from '../../types';
import {
filterOutNullableValues,
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
} from '../utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '../translations';
import { DefaultActionsSupportedValue } from '../types';
const ICON = 'plusInCircle';
const FILTER_IN = i18n.translate('cellActions.actions.filterIn', {
@ -18,7 +26,13 @@ const FILTER_IN = i18n.translate('cellActions.actions.filterIn', {
});
export const createFilterInActionFactory = createCellActionFactory(
({ filterManager }: { filterManager: FilterManager }) => ({
({
filterManager,
notifications: { toasts },
}: {
filterManager: FilterManager;
notifications: NotificationsStart;
}) => ({
type: FILTER_CELL_ACTION_TYPE,
getIconType: () => ICON,
getDisplayName: () => FILTER_IN,
@ -28,13 +42,22 @@ export const createFilterInActionFactory = createCellActionFactory(
return (
data.length === 1 && // TODO Add support for multiple values
!!field.name
!!field.name &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data }) => {
const field = data[0]?.field;
const value = data[0]?.value;
addFilterIn({ filterManager, fieldName: field.name, value });
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
if (isValueSupportedByDefaultActions(value)) {
addFilterIn({ filterManager, fieldName: field.name, value });
} else {
toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
}
},
})
);
@ -46,7 +69,7 @@ export const addFilterIn = ({
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: CellActionFieldValue;
value: DefaultActionsSupportedValue;
}) => {
if (filterManager != null) {
const filter = createFilter({

View file

@ -5,9 +5,10 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { FilterManager } from '@kbn/data-plugin/public';
import { FilterManager, KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { createFilterOutActionFactory } from './filter_out';
import { makeActionContext } from '../../mocks/helpers';
import { NotificationsStart } from '@kbn/core-notifications-browser';
const mockFilterManager = { addFilters: jest.fn() } as unknown as FilterManager;
@ -20,13 +21,18 @@ jest.mock('./create_filter', () => ({
const fieldName = 'user.name';
const value = 'the value';
const mockWarningToast = jest.fn();
describe('createFilterOutAction', () => {
const filterOutActionFactory = createFilterOutActionFactory({ filterManager: mockFilterManager });
const filterOutActionFactory = createFilterOutActionFactory({
filterManager: mockFilterManager,
notifications: { toasts: { addWarning: mockWarningToast } } as unknown as NotificationsStart,
});
const filterOutAction = filterOutActionFactory({ id: 'testAction' });
const context = makeActionContext({
data: [
{
field: { name: fieldName, type: 'text', searchable: true, aggregatable: true },
field: { name: fieldName, type: 'string', searchable: true, aggregatable: true },
value,
},
],
@ -61,6 +67,20 @@ describe('createFilterOutAction', () => {
})
).toEqual(false);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await filterOutAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES._SOURCE },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -71,7 +91,11 @@ describe('createFilterOutAction', () => {
it('should create negate filter query with value', async () => {
await filterOutAction.execute(context);
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value, negate: true });
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: [value],
negate: true,
});
});
it('should create negate filter query with array value', async () => {
@ -101,7 +125,7 @@ describe('createFilterOutAction', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: null, negate: false });
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: false });
});
it('should create filter query with undefined value', async () => {
@ -116,7 +140,7 @@ describe('createFilterOutAction', () => {
});
expect(mockCreateFilter).toHaveBeenCalledWith({
key: fieldName,
value: undefined,
value: [],
negate: false,
});
});
@ -131,7 +155,7 @@ describe('createFilterOutAction', () => {
},
],
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: '', negate: false });
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [''], negate: false });
});
it('should create negate filter query with empty array value', async () => {
@ -146,5 +170,19 @@ describe('createFilterOutAction', () => {
});
expect(mockCreateFilter).toHaveBeenCalledWith({ key: fieldName, value: [], negate: false });
});
it('should notify the user when value type is unsupported', async () => {
await filterOutAction.execute({
...context,
data: [
{
...context.data[0],
value: { a: {} },
},
],
});
expect(mockCreateFilter).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
});

View file

@ -6,11 +6,19 @@
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { FilterManager } from '@kbn/data-plugin/public';
import type { FilterManager, KBN_FIELD_TYPES } from '@kbn/data-plugin/public';
import { NotificationsStart } from '@kbn/core-notifications-browser';
import { createFilter, isEmptyFilterValue } from './create_filter';
import { FILTER_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
import { CellActionFieldValue } from '../../types';
import {
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
filterOutNullableValues,
} from '../utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '../translations';
import { DefaultActionsSupportedValue } from '../types';
const ICON = 'minusInCircle';
const FILTER_OUT = i18n.translate('cellActions.actions.filterOut', {
@ -18,7 +26,13 @@ const FILTER_OUT = i18n.translate('cellActions.actions.filterOut', {
});
export const createFilterOutActionFactory = createCellActionFactory(
({ filterManager }: { filterManager: FilterManager }) => ({
({
filterManager,
notifications: { toasts },
}: {
filterManager: FilterManager;
notifications: NotificationsStart;
}) => ({
type: FILTER_CELL_ACTION_TYPE,
getIconType: () => ICON,
getDisplayName: () => FILTER_OUT,
@ -28,18 +42,27 @@ export const createFilterOutActionFactory = createCellActionFactory(
return (
data.length === 1 && // TODO Add support for multiple values
!!field.name
!!field.name &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data }) => {
const field = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
addFilterOut({
filterManager,
fieldName: field.name,
value,
});
if (isValueSupportedByDefaultActions(value)) {
addFilterOut({
filterManager,
fieldName: field.name,
value,
});
} else {
toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
}
},
})
);
@ -51,7 +74,7 @@ export const addFilterOut = ({
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: CellActionFieldValue;
value: DefaultActionsSupportedValue;
}) => {
if (filterManager != null) {
const filter = createFilter({

View file

@ -6,8 +6,11 @@
* Side Public License, v 1.
*/
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { SUPPORTED_KBN_TYPES } from './constants';
import { i18n } from '@kbn/i18n';
export const isTypeSupportedByCellActions = (kbnFieldType: KBN_FIELD_TYPES) =>
SUPPORTED_KBN_TYPES.includes(kbnFieldType);
export const ACTION_INCOMPATIBLE_VALUE_WARNING = i18n.translate(
'cellActions.actions.incompatibility.warningMessage',
{
defaultMessage: 'The action can not be executed because the value and type are incompatible',
}
);

View file

@ -0,0 +1,19 @@
/*
* 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 { SerializableRecord } from '@kbn/utility-types';
import { SerializableArray } from '@kbn/utility-types/src/serializable';
export type DefaultActionsSupportedValue = string[] | number[] | boolean[];
export type NonNullableSerializable =
| string
| number
| boolean
| SerializableArray
| SerializableRecord;

View file

@ -0,0 +1,74 @@
/*
* 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 {
filterOutNullableValues,
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
} from './utils';
describe('utils', () => {
describe('isTypeSupportedByDefaultActions', () => {
it('returns true when the type is number', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.NUMBER)).toBe(true);
});
it('returns true when the type is string', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.STRING)).toBe(true);
});
it('returns true when the type is ip', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.IP)).toBe(true);
});
it('returns true when the type is date', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.DATE)).toBe(true);
});
it('returns true when the type is boolean', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.BOOLEAN)).toBe(true);
});
it('returns false when the type is unknown', () => {
expect(isTypeSupportedByDefaultActions(KBN_FIELD_TYPES.UNKNOWN)).toBe(false);
});
});
describe('isValueSupportedByDefaultActions', () => {
it('returns true when the value is an array of strings', () => {
expect(isValueSupportedByDefaultActions(['string', 'string'])).toBe(true);
});
it('returns true when the value is an array of number', () => {
expect(isValueSupportedByDefaultActions([2, 2])).toBe(true);
});
it('returns true when the value is an empty array', () => {
expect(isValueSupportedByDefaultActions([])).toBe(true);
});
it('returns true when the value is an array of booleans', () => {
expect(isValueSupportedByDefaultActions([false, true])).toBe(true);
});
it('returns false when the value is an mixed-type array', () => {
expect(isValueSupportedByDefaultActions([2, 'string', false])).toBe(false);
});
});
describe('filterOutNullableValues', () => {
it('returns empty array when all values are nullable', () => {
expect(filterOutNullableValues([null, undefined, null, undefined])).toEqual([]);
});
it('returns the same elements when they are all non-nullable', () => {
expect(filterOutNullableValues([2, 'string', true])).toEqual([2, 'string', true]);
});
});
});

View file

@ -0,0 +1,39 @@
/*
* 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 { isBoolean, isNumber, isString } from 'lodash/fp';
import { Serializable, SerializableArray } from '@kbn/utility-types/src/serializable';
import { DefaultActionsSupportedValue, NonNullableSerializable } from './types';
export const SUPPORTED_KBN_TYPES = [
KBN_FIELD_TYPES.DATE,
KBN_FIELD_TYPES.IP,
KBN_FIELD_TYPES.STRING,
KBN_FIELD_TYPES.NUMBER,
KBN_FIELD_TYPES.BOOLEAN,
];
export const isTypeSupportedByDefaultActions = (kbnFieldType: KBN_FIELD_TYPES) =>
SUPPORTED_KBN_TYPES.includes(kbnFieldType);
const isNonMixedTypeArray = (
value: Array<string | number | boolean>
): value is string[] | number[] | boolean[] => value.every((v) => typeof v === typeof value[0]);
export const isValueSupportedByDefaultActions = (
value: NonNullableSerializable[]
): value is DefaultActionsSupportedValue =>
value.every((v): v is string | number | boolean => isString(v) || isNumber(v) || isBoolean(v)) &&
isNonMixedTypeArray(value);
export const filterOutNullableValues = (value: SerializableArray): NonNullableSerializable[] =>
value.filter<NonNullableSerializable>((v): v is NonNullableSerializable => v != null);
export const valueToArray = (value: Serializable): SerializableArray =>
Array.isArray(value) ? value : [value];

View file

@ -6,8 +6,6 @@
* 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';
@ -16,11 +14,3 @@ 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,
KBN_FIELD_TYPES.BOOLEAN,
];

View file

@ -11,6 +11,7 @@ import type {
UiActionsService,
} from '@kbn/ui-actions-plugin/public';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import { Serializable } from '@kbn/utility-types';
import type { CellActionsMode } from './constants';
export interface CellActionsProviderProps {
@ -24,14 +25,14 @@ export interface CellActionsProviderProps {
type Metadata = Record<string, unknown>;
export type CellActionFieldValue =
| string
| number
| boolean
| Serializable
// Add primitive array types to allow type guards to work.
// Because SerializableArray is a cyclic self referenced Array.
| string[]
| number[]
| boolean[]
| null
| undefined;
| null[]
| undefined[];
export interface CellActionsData {
/**

View file

@ -1,36 +0,0 @@
/*
* 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 true if the type is boolean', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.BOOLEAN)).toBe(true);
});
it('returns false if the type is unknown', () => {
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.UNKNOWN)).toBe(false);
});
});

View file

@ -21,6 +21,8 @@
"@kbn/ui-actions-plugin",
"@kbn/field-types",
"@kbn/data-views-plugin",
"@kbn/core-notifications-browser",
"@kbn/utility-types",
],
"exclude": ["target/**/*"]
}

View file

@ -275,42 +275,6 @@ describe('DiscoverGrid', () => {
})
);
});
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', () => {

View file

@ -29,14 +29,12 @@ 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 { Serializable } from '@kbn/utility-types';
import { DocViewFilterFn } from '../../services/doc_views/doc_views_types';
import { getSchemaDetectors } from './discover_grid_schema';
import { DiscoverGridFlyout } from './discover_grid_flyout';
@ -451,7 +449,7 @@ export const DiscoverGrid = ({
const getCellValue = useCallback<UseDataGridColumnsCellActionsProps['getCellValue']>(
(fieldName, rowIndex) =>
displayedRows[rowIndex % displayedRows.length].flattened[fieldName] as CellActionFieldValue,
displayedRows[rowIndex % displayedRows.length].flattened[fieldName] as Serializable,
[displayedRows]
);
@ -460,8 +458,7 @@ export const DiscoverGrid = ({
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
if (!field) {
return {
name: '',
type: '',

View file

@ -338,7 +338,7 @@ export const DataTableComponent = React.memo<DataTableProps>(
? // TODO use FieldSpec object instead of column
columnHeaders.map((column) => ({
name: column.id,
type: column.type ?? 'keyword',
type: column.type ?? '', // When type is an empty string all cell actions are incompatible
aggregatable: column.aggregatable ?? false,
searchable: column.searchable ?? false,
esTypes: column.esTypes ?? [],

View file

@ -13,6 +13,7 @@ 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 { set } from 'lodash/fp';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
const services = createStartServicesMock();
const mockWarningToast = services.notifications.toasts.addWarning;
@ -27,7 +28,7 @@ const value = 'the-value';
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value,
},
],
@ -75,6 +76,7 @@ describe('createAddToTimelineCellAction', () => {
it('should return true if everything is okay', async () => {
expect(await addToTimelineAction.isCompatible(context)).toEqual(true);
});
it('should return false if field not allowed', async () => {
expect(
await addToTimelineAction.isCompatible({
@ -88,6 +90,20 @@ describe('createAddToTimelineCellAction', () => {
})
).toEqual(false);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await addToTimelineAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.DATE_RANGE },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -190,6 +206,20 @@ describe('createAddToTimelineCellAction', () => {
expect(mockWarningToast).toHaveBeenCalled();
});
it('should show warning if value type is unsupported', async () => {
await addToTimelineAction.execute({
...context,
data: [
{
...context.data[0],
value: {},
},
],
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
describe('should execute correctly when negateFilters is provided', () => {
it('should not exclude if negateFilters is false', async () => {
await addToTimelineAction.execute({

View file

@ -7,6 +7,14 @@
import { createCellActionFactory } from '@kbn/cell-actions';
import type { CellActionTemplate } from '@kbn/cell-actions';
import {
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
filterOutNullableValues,
valueToArray,
} from '@kbn/cell-actions/src/actions/utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import type { KBN_FIELD_TYPES } from '@kbn/field-types';
import { addProvider } from '../../../timelines/store/timeline/actions';
import { TimelineId } from '../../../../common/types';
import type { SecurityAppStore } from '../../../common/store';
@ -44,15 +52,24 @@ export const createAddToTimelineCellActionFactory = createCellActionFactory(
return (
data.length === 1 && // TODO Add support for multiple values
fieldHasCellActions(field.name) &&
isValidDataProviderField(field.name, field.type)
isValidDataProviderField(field.name, field.type) &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data, metadata }) => {
const { name, type } = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
const values = Array.isArray(value) ? value : [value];
const [firstValue, ...andValues] = values;
if (!isValueSupportedByDefaultActions(value)) {
notificationsService.toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
return;
}
const [firstValue, ...andValues] = value;
const [dataProvider] =
createDataProviders({
contextId: TimelineId.active,
@ -81,10 +98,7 @@ export const createAddToTimelineCellActionFactory = createCellActionFactory(
if (dataProvider) {
store.dispatch(addProvider({ id: TimelineId.active, providers: [dataProvider] }));
let messageValue = '';
if (value != null) {
messageValue = Array.isArray(value) ? value.join(', ') : value.toString();
}
const messageValue = value.join(', ');
notificationsService.toasts.addSuccess({
title: ADD_TO_TIMELINE_SUCCESS_TITLE(messageValue),
});

View file

@ -13,6 +13,7 @@ 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 { timelineActions } from '../../../timelines/store/timeline';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
const services = createStartServicesMock();
const mockWarningToast = services.notifications.toasts.addWarning;
@ -27,7 +28,7 @@ const value = 'the-value';
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value,
},
],
@ -78,6 +79,7 @@ describe('createAddToNewTimelineCellAction', () => {
it('should return true if everything is okay', async () => {
expect(await addToTimelineAction.isCompatible(context)).toEqual(true);
});
it('should return false if field not allowed', async () => {
expect(
await addToTimelineAction.isCompatible({
@ -91,6 +93,20 @@ describe('createAddToNewTimelineCellAction', () => {
})
).toEqual(false);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await addToTimelineAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.NESTED },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -114,6 +130,20 @@ describe('createAddToNewTimelineCellAction', () => {
expect(mockWarningToast).toHaveBeenCalled();
});
it('should show warning if value type is unsupported', async () => {
await addToTimelineAction.execute({
...context,
data: [
{
...context.data[0],
value: [[[]]],
},
],
});
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
describe('should execute correctly when negateFilters is provided', () => {
it('should not exclude if negateFilters is false', async () => {
await addToTimelineAction.execute({

View file

@ -6,6 +6,14 @@
*/
import { createCellActionFactory, type CellActionTemplate } from '@kbn/cell-actions';
import type { KBN_FIELD_TYPES } from '@kbn/field-types';
import {
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
filterOutNullableValues,
} from '@kbn/cell-actions/src/actions/utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import { timelineActions } from '../../../timelines/store/timeline';
import { addProvider, showTimeline } from '../../../timelines/store/timeline/actions';
import { TimelineId } from '../../../../common/types';
@ -44,12 +52,21 @@ export const createInvestigateInNewTimelineCellActionFactory = createCellActionF
return (
data.length === 1 && // TODO Add support for multiple values
fieldHasCellActions(field.name) &&
isValidDataProviderField(field.name, field.type)
isValidDataProviderField(field.name, field.type) &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
notificationsService.toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
return;
}
const dataProviders =
createDataProviders({

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import type { CellActionFieldValue } from '@kbn/cell-actions/src/types';
import { escapeDataProviderId } from '@kbn/securitysolution-t-grid';
import type { Serializable } from '@kbn/utility-types';
@ -61,7 +60,7 @@ export interface CreateDataProviderParams {
field?: string;
fieldFormat?: string;
fieldType?: string;
values: CellActionFieldValue;
values: string | string[] | number | number[] | boolean | boolean[];
sourceParamType?: Serializable;
negate?: boolean;
}
@ -78,7 +77,11 @@ export const createDataProviders = ({
}: CreateDataProviderParams) => {
if (field == null) return null;
const arrayValues = Array.isArray(values) ? (values.length > 0 ? values : [null]) : [values];
const arrayValues: Array<string | number | boolean | null> = Array.isArray(values)
? values.length > 0
? values
: [null]
: [values];
return arrayValues.reduce<DataProvider[]>((dataProviders, rawValue, index) => {
let id: string = '';

View file

@ -29,7 +29,7 @@ const store = {
const value = 'the-value';
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value }],
data: [{ field: { name: 'user.name', type: 'string' }, value }],
} as CellActionExecutionContext;
const defaultDataProvider = {

View file

@ -19,7 +19,7 @@ describe('createCopyToClipboardCellActionFactory', () => {
const copyToClipboardActionFactory = createCopyToClipboardCellActionFactory({ services });
const copyToClipboardAction = copyToClipboardActionFactory({ id: 'testAction' });
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value: 'the value' }],
data: [{ field: { name: 'user.name', type: 'string' }, value: 'the value' }],
} as CellActionExecutionContext;
beforeEach(() => {

View file

@ -25,10 +25,7 @@ export const createCopyToClipboardCellActionFactory = ({
isCompatible: async ({ data }) => {
const field = data[0]?.field;
return (
data.length === 1 && // TODO Add support for multiple values
fieldHasCellActions(field.name)
);
return fieldHasCellActions(field.name);
},
});
};

View file

@ -26,7 +26,7 @@ describe('createCopyToClipboardDiscoverCellActionFactory', () => {
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value: 'the value',
},
],

View file

@ -18,10 +18,12 @@ import type { SecurityCellActionExecutionContext } from '../../types';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { TableId } from '@kbn/securitysolution-data-table';
import { TimelineId } from '../../../../common/types';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
const services = createStartServicesMock();
const mockGlobalFilterManager = services.data.query.filterManager;
const mockTimelineFilterManager = createFilterManagerMock();
const mockWarningToast = services.notifications.toasts.addWarning;
const mockState = {
...mockGlobalState,
@ -57,7 +59,7 @@ describe('createFilterInCellActionFactory', () => {
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value: 'the value',
},
],
@ -75,18 +77,34 @@ describe('createFilterInCellActionFactory', () => {
it('should return true if everything is okay', async () => {
expect(await filterInAction.isCompatible(context)).toEqual(true);
});
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);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await filterInAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.HISTOGRAM },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -101,6 +119,21 @@ describe('createFilterInCellActionFactory', () => {
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
});
it('should show warning if value type is unsupported', async () => {
await filterInAction.execute({
...dataTableContext,
data: [
{
...context.data[0],
value: { test: '123' },
},
],
});
expect(mockGlobalFilterManager.addFilters).not.toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
describe('timeline scope execution', () => {

View file

@ -6,6 +6,14 @@
*/
import { addFilterIn, addFilterOut, createFilterInActionFactory } from '@kbn/cell-actions';
import {
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
filterOutNullableValues,
} from '@kbn/cell-actions/src/actions/utils';
import type { KBN_FIELD_TYPES } from '@kbn/field-types';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import type { SecurityAppStore } from '../../../common/store';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { fieldHasCellActions } from '../../utils';
@ -25,7 +33,11 @@ export const createFilterInCellActionFactory = ({
const getTimelineById = timelineSelectors.getTimelineByIdSelector();
const { filterManager } = services.data.query;
const genericFilterInActionFactory = createFilterInActionFactory({ filterManager });
const { notifications } = services;
const genericFilterInActionFactory = createFilterInActionFactory({
filterManager,
notifications,
});
return genericFilterInActionFactory.combine<SecurityCellAction>({
type: SecurityCellActionType.FILTER,
@ -34,12 +46,21 @@ export const createFilterInCellActionFactory = ({
return (
data.length === 1 && // TODO Add support for multiple values
fieldHasCellActions(field.name)
fieldHasCellActions(field.name) &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
notifications.toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
return;
}
if (!field) return;

View file

@ -18,10 +18,12 @@ import type { SecurityCellActionExecutionContext } from '../../types';
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
import { TimelineId } from '../../../../common/types';
import { TableId } from '@kbn/securitysolution-data-table';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
const services = createStartServicesMock();
const mockGlobalFilterManager = services.data.query.filterManager;
const mockTimelineFilterManager = createFilterManagerMock();
const mockWarningToast = services.notifications.toasts.addWarning;
const mockState = {
...mockGlobalState,
@ -51,7 +53,7 @@ describe('createFilterOutCellActionFactory', () => {
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value: 'the value',
},
],
@ -69,6 +71,7 @@ describe('createFilterOutCellActionFactory', () => {
it('should return true if everything is okay', async () => {
expect(await filterOutAction.isCompatible(context)).toEqual(true);
});
it('should return false if field not allowed', async () => {
expect(
await filterOutAction.isCompatible({
@ -81,6 +84,20 @@ describe('createFilterOutCellActionFactory', () => {
})
).toEqual(false);
});
it('should return false if Kbn type is unsupported', async () => {
expect(
await filterOutAction.isCompatible({
...context,
data: [
{
...context.data[0],
field: { ...context.data[0].field, type: KBN_FIELD_TYPES.OBJECT },
},
],
})
).toEqual(false);
});
});
describe('execute', () => {
@ -95,6 +112,21 @@ describe('createFilterOutCellActionFactory', () => {
expect(mockGlobalFilterManager.addFilters).toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
});
it('should show warning if value type is unsupported', async () => {
await filterOutAction.execute({
...dataTableContext,
data: [
{
...context.data[0],
value: [{ test: 'value' }],
},
],
});
expect(mockGlobalFilterManager.addFilters).not.toHaveBeenCalled();
expect(mockTimelineFilterManager.addFilters).not.toHaveBeenCalled();
expect(mockWarningToast).toHaveBeenCalled();
});
});
describe('timeline scope execution', () => {

View file

@ -6,6 +6,14 @@
*/
import { addFilterIn, addFilterOut, createFilterOutActionFactory } from '@kbn/cell-actions';
import {
isTypeSupportedByDefaultActions,
isValueSupportedByDefaultActions,
valueToArray,
filterOutNullableValues,
} from '@kbn/cell-actions/src/actions/utils';
import { ACTION_INCOMPATIBLE_VALUE_WARNING } from '@kbn/cell-actions/src/actions/translations';
import type { KBN_FIELD_TYPES } from '@kbn/field-types';
import { fieldHasCellActions } from '../../utils';
import type { SecurityAppStore } from '../../../common/store';
import type { StartServices } from '../../../types';
@ -25,7 +33,12 @@ export const createFilterOutCellActionFactory = ({
const getTimelineById = timelineSelectors.getTimelineByIdSelector();
const { filterManager } = services.data.query;
const genericFilterOutActionFactory = createFilterOutActionFactory({ filterManager });
const { notifications } = services;
const genericFilterOutActionFactory = createFilterOutActionFactory({
filterManager,
notifications,
});
return genericFilterOutActionFactory.combine<SecurityCellAction>({
type: SecurityCellActionType.FILTER,
@ -34,14 +47,24 @@ export const createFilterOutCellActionFactory = ({
return (
data.length === 1 && // TODO Add support for multiple values
fieldHasCellActions(field.name)
fieldHasCellActions(field.name) &&
isTypeSupportedByDefaultActions(field.type as KBN_FIELD_TYPES)
);
},
execute: async ({ data, metadata }) => {
const field = data[0]?.field;
const value = data[0]?.value;
const rawValue = data[0]?.value;
const value = filterOutNullableValues(valueToArray(rawValue));
if (!isValueSupportedByDefaultActions(value)) {
notifications.toasts.addWarning({
title: ACTION_INCOMPATIBLE_VALUE_WARNING,
});
return;
}
if (!field) return;
// if negateFilters is true we have to perform the opposite operation, we can just execute filterIn with the same params
const addFilter = metadata?.negateFilters === true ? addFilterIn : addFilterOut;

View file

@ -46,7 +46,7 @@ describe('createFilterInDiscoverCellActionFactory', () => {
});
const context = {
data: [{ field: { name: 'user.name', type: 'text' }, value: 'the value' }],
data: [{ field: { name: 'user.name', type: 'string' }, value: 'the value' }],
} as SecurityCellActionExecutionContext;
it('should return display name', () => {

View file

@ -48,7 +48,7 @@ describe('createFilterOutDiscoverCellActionFactory', () => {
const context = {
data: [
{
field: { name: 'user.name', type: 'text' },
field: { name: 'user.name', type: 'string' },
value: 'the value',
},
],

View file

@ -29,7 +29,7 @@ const SHOW_TOP = (fieldName: string) =>
});
const ICON = 'visBarVertical';
const UNSUPPORTED_FIELD_TYPES = [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.TEXT];
const UNSUPPORTED_ES_FIELD_TYPES = [ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.TEXT];
export const createShowTopNCellActionFactory = createCellActionFactory(
({
@ -52,7 +52,7 @@ export const createShowTopNCellActionFactory = createCellActionFactory(
data.length === 1 &&
fieldHasCellActions(field.name) &&
(field.esTypes ?? []).every(
(esType) => !UNSUPPORTED_FIELD_TYPES.includes(esType as ES_FIELD_TYPES)
(esType) => !UNSUPPORTED_ES_FIELD_TYPES.includes(esType as ES_FIELD_TYPES)
) &&
!!field.aggregatable
);

View file

@ -72,7 +72,7 @@ export const getUseCellActionsHook = (tableId: TableId) => {
const browserField: Partial<BrowserField> | undefined = browserFieldsByName[column.id];
return {
name: column.id,
type: browserField?.type ?? 'keyword',
type: browserField?.type ?? '', // When type is an empty string all cell actions are incompatible
esTypes: browserField?.esTypes ?? [],
aggregatable: browserField?.aggregatable ?? false,
searchable: browserField?.searchable ?? false,