mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
b8ef39a232
commit
ee91d084f5
21 changed files with 89 additions and 55 deletions
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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]',
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -52,7 +52,6 @@ export const TopNAction = ({
|
|||
showLegend
|
||||
scopeId={metadata?.scopeId}
|
||||
toggleTopN={onClose}
|
||||
value={firstItem.value}
|
||||
indexPattern={indexPattern}
|
||||
browserFields={browserFields}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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> = ({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue