[SecuritySolutions] Add support for Number and Boolean types to CellActions (#160095)

## Summary
Add support for `Boolean` and `Number` types to CellActions and update
Security Actions accordingly.
It also fixes the copy-to-clipboard action for fields of the type number
(`process.parent.pid`).

issue: https://github.com/elastic/kibana/issues/159298

- [x] Remove discover fields value casting if it gets merged after the
[discover PR](https://github.com/elastic/kibana/pull/157201)


### How to test it?

The quickest way is to find an alert field that is a boolean or number
on the alerts page and check if security solution actions still work.
But all boolean fields that I tested are actually strings. 🤷
Alternatively, you could render the `<SecurityCellActions />` with fake
data (boolean and number).

### Checklist

- [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: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Pablo Machado 2023-06-26 15:49:56 +02:00 committed by GitHub
parent b8ef39a232
commit ee91d084f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 89 additions and 55 deletions

View file

@ -68,7 +68,7 @@ describe('Default createCopyToClipboardActionFactory', () => {
expect(mockSuccessToast).toHaveBeenCalled();
});
it('should suport multiple values', async () => {
it('should support multiple values', async () => {
await copyToClipboardAction.execute({
...context,
data: [
@ -83,5 +83,33 @@ describe('Default createCopyToClipboardActionFactory', () => {
);
expect(mockSuccessToast).toHaveBeenCalled();
});
it('should support numbers', async () => {
await copyToClipboardAction.execute({
...context,
data: [
{
...context.data[0],
value: [1, 2, 3],
},
],
});
expect(mockCopy).toHaveBeenCalledWith('user.name: 1 AND 2 AND 3');
expect(mockSuccessToast).toHaveBeenCalled();
});
it('should support booleans', async () => {
await copyToClipboardAction.execute({
...context,
data: [
{
...context.data[0],
value: [true, false, true],
},
],
});
expect(mockCopy).toHaveBeenCalledWith('user.name: true AND false AND true');
expect(mockSuccessToast).toHaveBeenCalled();
});
});
});

View file

@ -9,6 +9,7 @@
import copy from 'copy-to-clipboard';
import { i18n } from '@kbn/i18n';
import type { NotificationsStart } from '@kbn/core/public';
import { isString } from 'lodash/fp';
import { COPY_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
@ -45,9 +46,8 @@ export const createCopyToClipboardActionFactory = createCellActionFactory(
let textValue: undefined | string;
if (value != null) {
textValue = Array.isArray(value)
? value.map((v) => `"${escapeValue(v)}"`).join(' AND ')
: `"${escapeValue(value)}"`;
const valuesArray = Array.isArray(value) ? value : [value];
textValue = valuesArray.map((v) => (isString(v) ? `"${escapeValue(v)}"` : v)).join(' AND ');
}
const text = textValue ? `${field.name}: ${textValue}` : field.name;
const isSuccess = copy(text, { debug: true });

View file

@ -10,25 +10,31 @@ import { createFilter } from './create_filter';
const field = 'field.name';
const value = 'the-value';
const numberValue = 123;
const booleanValue = true;
describe('createFilter', () => {
it.each([
{ caseName: 'string', caseValue: value },
{ caseName: 'array', caseValue: [value] },
])('should return filter with $caseName value', ({ caseValue }) => {
{ 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({
meta: {
type: 'phrase',
key: field,
negate: false,
params: {
query: value,
query,
},
},
query: {
match_phrase: {
[field]: {
query: value,
query,
},
},
},
@ -37,21 +43,25 @@ describe('createFilter', () => {
it.each([
{ caseName: 'string', caseValue: value },
{ caseName: 'array', caseValue: [value] },
])('should return negate filter with $caseName value', ({ caseValue }) => {
{ 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({
meta: {
type: 'phrase',
key: field,
negate: true,
params: {
query: value,
query,
},
},
query: {
match_phrase: {
[field]: {
query: value,
query,
},
},
},

View file

@ -13,10 +13,13 @@ import {
type PhraseFilter,
type Filter,
} from '@kbn/es-query';
import { isArray } from 'lodash/fp';
import { CellActionFieldValue } from '../../types';
export const isEmptyFilterValue = (
value: string[] | string | null | undefined
): value is null | undefined | never[] => value == null || value.length === 0;
value: CellActionFieldValue
): value is null | undefined | never[] =>
value == null || value === '' || (isArray(value) && value.length === 0);
const createExistsFilter = ({ key, negate }: { key: string; negate: boolean }): ExistsFilter => ({
meta: { key, negate, type: FILTERS.EXISTS, value: 'exists' },
@ -28,12 +31,17 @@ const createPhraseFilter = ({
negate,
value,
}: {
value: string;
value: string | number | boolean;
key: string;
negate?: boolean;
}): PhraseFilter => ({
meta: { key, negate, type: FILTERS.PHRASE, params: { query: value } },
query: { match_phrase: { [key]: { query: value } } },
meta: {
key,
negate,
type: FILTERS.PHRASE,
params: { query: value.toString() },
},
query: { match_phrase: { [key]: { query: value.toString() } } },
});
const createCombinedFilter = ({
@ -41,7 +49,7 @@ const createCombinedFilter = ({
key,
negate,
}: {
values: string[];
values: string[] | number[] | boolean[];
key: string;
negate: boolean;
}): CombinedFilter => ({
@ -60,7 +68,7 @@ export const createFilter = ({
negate,
}: {
key: string;
value: string[] | string | null | undefined;
value: CellActionFieldValue;
negate: boolean;
}): Filter => {
if (isEmptyFilterValue(value)) {

View file

@ -10,6 +10,7 @@ import type { FilterManager } from '@kbn/data-plugin/public';
import { createFilter, isEmptyFilterValue } from './create_filter';
import { FILTER_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
import { CellActionFieldValue } from '../../types';
const ICON = 'plusInCircle';
const FILTER_IN = i18n.translate('cellActions.actions.filterIn', {
@ -45,7 +46,7 @@ export const addFilterIn = ({
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: string[] | string | null | undefined;
value: CellActionFieldValue;
}) => {
if (filterManager != null) {
const filter = createFilter({

View file

@ -10,6 +10,7 @@ import type { FilterManager } from '@kbn/data-plugin/public';
import { createFilter, isEmptyFilterValue } from './create_filter';
import { FILTER_CELL_ACTION_TYPE } from '../../constants';
import { createCellActionFactory } from '../factory';
import { CellActionFieldValue } from '../../types';
const ICON = 'minusInCircle';
const FILTER_OUT = i18n.translate('cellActions.actions.filterOut', {
@ -50,7 +51,7 @@ export const addFilterOut = ({
}: {
filterManager: FilterManager | undefined;
fieldName: string;
value: string[] | string | null | undefined;
value: CellActionFieldValue;
}) => {
if (filterManager != null) {
const filter = createFilter({

View file

@ -21,5 +21,6 @@ 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
KBN_FIELD_TYPES.NUMBER,
KBN_FIELD_TYPES.BOOLEAN,
];

View file

@ -23,7 +23,15 @@ export interface CellActionsProviderProps {
type Metadata = Record<string, unknown>;
export type CellActionFieldValue = string | string[] | null | undefined;
export type CellActionFieldValue =
| string
| number
| boolean
| string[]
| number[]
| boolean[]
| null
| undefined;
export interface CellActionsData {
/**

View file

@ -26,11 +26,11 @@ describe('isTypeSupportedByCellActions', () => {
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 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.BOOLEAN)).toBe(false);
expect(isTypeSupportedByCellActions(KBN_FIELD_TYPES.UNKNOWN)).toBe(false);
});
});

View file

@ -61,7 +61,6 @@ 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 };
@ -451,8 +450,8 @@ export const DiscoverGrid = ({
);
const getCellValue = useCallback<UseDataGridColumnsCellActionsProps['getCellValue']>(
(fieldName, rowIndex): CellActionFieldValue =>
convertCellActionValue(displayedRows[rowIndex % displayedRows.length].flattened[fieldName]),
(fieldName, rowIndex) =>
displayedRows[rowIndex % displayedRows.length].flattened[fieldName] as CellActionFieldValue,
[displayedRows]
);

View file

@ -10,7 +10,6 @@ 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';
@ -121,12 +120,3 @@ 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

@ -100,7 +100,7 @@ describe('createAddToTimelineCellAction', () => {
it('should execute with number value', async () => {
await addToTimelineAction.execute({
data: [{ field: { name: 'process.parent.pid', type: 'number' }, value: 12345 }],
} as unknown as CellActionExecutionContext); // TODO: remove `as unknown` when number value type is supported
} as CellActionExecutionContext);
expect(mockDispatch).toHaveBeenCalledWith(
set(
'payload.providers[0]',

View file

@ -83,7 +83,7 @@ export const createAddToTimelineCellActionFactory = createCellActionFactory(
let messageValue = '';
if (value != null) {
messageValue = Array.isArray(value) ? value.join(', ') : value;
messageValue = Array.isArray(value) ? value.join(', ') : value.toString();
}
notificationsService.toasts.addSuccess({
title: ADD_TO_TIMELINE_SUCCESS_TITLE(messageValue),

View file

@ -5,6 +5,7 @@
* 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';
@ -60,7 +61,7 @@ export interface CreateDataProviderParams {
field?: string;
fieldFormat?: string;
fieldType?: string;
values: string | string[] | null | undefined;
values: CellActionFieldValue;
sourceParamType?: Serializable;
negate?: boolean;
}

View file

@ -52,7 +52,6 @@ export const TopNAction = ({
showLegend
scopeId={metadata?.scopeId}
toggleTopN={onClose}
value={firstItem.value}
indexPattern={indexPattern}
browserFields={browserFields}
/>

View file

@ -48,7 +48,6 @@ describe('show topN button', () => {
ownFocus: false,
showTopN: false,
scopeId: TimelineId.active,
value: ['rule_name'],
};
describe('button', () => {
@ -182,7 +181,6 @@ describe('show topN button', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="top-n"]').prop('field')).toEqual(testProps.field);
expect(wrapper.find('[data-test-subj="top-n"]').prop('value')).toEqual(testProps.value);
expect(wrapper.find('[data-test-subj="top-n"]').prop('toggleTopN')).toEqual(
testProps.onClick
);

View file

@ -146,7 +146,6 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
showLegend={showLegend}
scopeId={scopeId ?? undefined}
toggleTopN={onClick}
value={value}
globalFilters={globalFilters}
/>
),
@ -159,7 +158,6 @@ export const ShowTopNButton: React.FC<Props> = React.memo(
showLegend,
scopeId,
onClick,
value,
globalFilters,
]
);

View file

@ -46,7 +46,6 @@ jest.mock('../../../timelines/store/timeline/actions');
jest.mock('../visualization_actions/actions');
jest.mock('../visualization_actions/lens_embeddable');
const field = 'process.name';
const value = 'nice';
const state: State = {
...mockGlobalState,
@ -160,7 +159,6 @@ const testProps = {
scopeId: TableId.hostsPageEvents,
toggleTopN: jest.fn(),
onFilterAdded: jest.fn(),
value,
};
describe('StatefulTopN', () => {

View file

@ -83,7 +83,6 @@ export interface OwnProps {
onFilterAdded?: () => void;
paddingSize?: 's' | 'm' | 'l' | 'none';
showLegend?: boolean;
value?: string[] | string | null;
globalFilters?: Filter[];
}
type PropsFromRedux = ConnectedProps<typeof connector>;
@ -107,7 +106,6 @@ const StatefulTopNComponent: React.FC<Props> = ({
showLegend,
scopeId,
toggleTopN,
value,
}) => {
const { uiSettings } = useKibana().services;
const { from, deleteQuery, setQuery, to } = useGlobalTime();
@ -169,7 +167,6 @@ const StatefulTopNComponent: React.FC<Props> = ({
to={isActiveTimeline(scopeId ?? '') ? activeTimelineTo : to}
toggleTopN={toggleTopN}
onFilterAdded={onFilterAdded}
value={value}
/>
);
};

View file

@ -44,7 +44,6 @@ jest.mock('uuid', () => {
});
const field = 'host.name';
const value = 'nice';
const combinedQueries = {
bool: {
must: [],
@ -116,7 +115,6 @@ describe('TopN', () => {
setQuery: jest.fn(),
to: '2020-04-15T00:31:47.695Z',
toggleTopN,
value,
};
describe('common functionality', () => {
let wrapper: ReactWrapper;

View file

@ -58,7 +58,6 @@ export interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery
scopeId?: string;
toggleTopN: () => void;
onFilterAdded?: () => void;
value?: string[] | string | null;
}
const TopNComponent: React.FC<Props> = ({