[Security Solution][Alert/Event Flyout] Add pinning and settings to table tab (#218686)

## Summary

This PR added enhancements to the table tab:
- User can now pin fields
- Highlighted fields have a highlighted background
- Added settings to:
   -  Filter table to only show highlighted fields
   - Hide empty values
   - Hide alert fields (`kibana.alert.*` and `signal.*`)

### 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/src/platform/packages/shared/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
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
christineweng 2025-04-23 15:20:34 -05:00 committed by GitHub
parent e73c8ddcb9
commit f15c96d6e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 920 additions and 104 deletions

View file

@ -0,0 +1,101 @@
/*
* 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 React from 'react';
import { render, act } from '@testing-library/react';
import { TableTabSettingButton } from './table_tab_setting_button';
import {
TABLE_TAB_SETTING_BUTTON_TEST_ID,
TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID,
TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID,
TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID,
} from './test_ids';
import userEvent from '@testing-library/user-event';
const mockTableTabState = {
pinnedFields: [],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: false,
};
const mockSetTableTabState = jest.fn();
const renderComponent = () => {
return render(
<TableTabSettingButton
tableTabState={mockTableTabState}
setTableTabState={mockSetTableTabState}
/>
);
};
describe('<TableTabSettingButton />', () => {
it('should render button', () => {
const { getByTestId } = renderComponent();
expect(getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should render highlighted fields only setting correctly', async () => {
const { getByTestId } = renderComponent();
const button = getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID);
expect(button).toBeInTheDocument();
act(async () => {
await userEvent.click(button);
const option = getByTestId(TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID);
expect(option).toBeInTheDocument();
expect(option).not.toBeChecked();
await userEvent.click(option);
expect(option).toBeChecked();
expect(mockSetTableTabState).toHaveBeenCalledWith({
...mockTableTabState,
showHighlightedFields: true,
});
});
});
it('should render hide empty fields setting correctly', async () => {
const { getByTestId } = renderComponent();
const button = getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID);
expect(button).toBeInTheDocument();
act(async () => {
await userEvent.click(button);
const option = getByTestId(TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID);
expect(option).toBeInTheDocument();
expect(option).not.toBeChecked();
await userEvent.click(option);
expect(option).toBeChecked();
expect(mockSetTableTabState).toHaveBeenCalledWith({
...mockTableTabState,
hideEmptyFields: true,
});
});
});
it('should render hide alert fields setting correctly', async () => {
const { getByTestId } = renderComponent();
const button = getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID);
expect(button).toBeInTheDocument();
act(async () => {
await userEvent.click(button);
const option = getByTestId(TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID);
expect(option).toBeInTheDocument();
expect(option).not.toBeChecked();
await userEvent.click(option);
expect(option).toBeChecked();
expect(mockSetTableTabState).toHaveBeenCalledWith({
...mockTableTabState,
hideAlertFields: true,
});
});
});
});

View file

@ -0,0 +1,162 @@
/*
* 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 React, { useState, useCallback } from 'react';
import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiSwitch,
EuiToolTip,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import type { TableTabState } from '../tabs/table_tab';
import {
TABLE_TAB_SETTING_BUTTON_TEST_ID,
TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID,
TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID,
TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID,
} from './test_ids';
const TABLE_TAB_SETTING_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.right.tableTabSettingButton.label',
{
defaultMessage: 'Table settings',
}
);
const HIGHLIGHTED_FIELDS_ONLY_LABEL = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.right.tableTabSettingButton.highlightedFieldsOnlyLabel',
{
defaultMessage: 'Show highlighted fields only',
}
);
const HIDE_EMPTY_FIELDS_LABEL = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.right.tableTabSettingButton.hideEmptyFieldsLabel',
{
defaultMessage: 'Hide empty fields',
}
);
const HIDE_ALERT_FIELDS_LABEL = i18n.translate(
'xpack.securitySolution.flyout.documentDetails.right.tableTabSettingButton.hideAlertFieldsLabel',
{
defaultMessage: 'Hide Kibana alert fields',
}
);
interface TableTabSettingButtonProps {
/**
* The current state of the table tab
*/
tableTabState: TableTabState;
/**
* The function to set the state of the table tab
*/
setTableTabState: (tableTabState: TableTabState) => void;
}
/**
* Settings button for the table tab.
*/
export const TableTabSettingButton = ({
tableTabState,
setTableTabState,
}: TableTabSettingButtonProps) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const { showHighlightedFields, hideEmptyFields, hideAlertFields } = tableTabState;
const { euiTheme } = useEuiTheme();
const onClick = useCallback(() => {
setIsPopoverOpen(!isPopoverOpen);
}, [isPopoverOpen]);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const onToggleShowHighlightedFields = useCallback(() => {
setTableTabState({
...tableTabState,
showHighlightedFields: !showHighlightedFields,
});
}, [showHighlightedFields, setTableTabState, tableTabState]);
const onToggleHideEmptyFields = useCallback(() => {
setTableTabState({
...tableTabState,
hideEmptyFields: !hideEmptyFields,
});
}, [hideEmptyFields, setTableTabState, tableTabState]);
const onToggleHideAlertFields = useCallback(() => {
setTableTabState({
...tableTabState,
hideAlertFields: !hideAlertFields,
});
}, [hideAlertFields, setTableTabState, tableTabState]);
return (
<EuiToolTip content={TABLE_TAB_SETTING_BUTTON_LABEL}>
<EuiPopover
button={
<EuiButtonIcon
aria-label={TABLE_TAB_SETTING_BUTTON_LABEL}
onClick={onClick}
iconType="gear"
size="m"
css={css`
border: 1px solid ${euiTheme.colors.backgroundLightText};
margin-left: -5px;
`}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
display="block"
data-test-subj={TABLE_TAB_SETTING_BUTTON_TEST_ID}
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart" direction="column">
<EuiFlexItem>
<EuiSwitch
data-test-subj={TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID}
label={HIGHLIGHTED_FIELDS_ONLY_LABEL}
aria-label={HIGHLIGHTED_FIELDS_ONLY_LABEL}
checked={showHighlightedFields}
onChange={onToggleShowHighlightedFields}
compressed
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
data-test-subj={TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID}
label={HIDE_EMPTY_FIELDS_LABEL}
aria-label={HIDE_EMPTY_FIELDS_LABEL}
checked={hideEmptyFields}
onChange={onToggleHideEmptyFields}
compressed
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiSwitch
data-test-subj={TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID}
label={HIDE_ALERT_FIELDS_LABEL}
aria-label={HIDE_ALERT_FIELDS_LABEL}
checked={hideAlertFields}
onChange={onToggleHideAlertFields}
compressed
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopover>
</EuiToolTip>
);
};

View file

@ -17,6 +17,14 @@ export const FLYOUT_TABLE_FIELD_NAME_CELL_TEXT_TEST_ID =
`${FLYOUT_TABLE_TEST_ID}FieldNameCellText` as const;
export const FLYOUT_TABLE_PREVIEW_LINK_FIELD_TEST_ID =
`${FLYOUT_TABLE_TEST_ID}PreviewLinkField` as const;
export const FLYOUT_TABLE_PIN_ACTION_TEST_ID = `${FLYOUT_TABLE_TEST_ID}PinAction` as const;
export const TABLE_TAB_SETTING_BUTTON_TEST_ID = `${PREFIX}TableTabSettingButton` as const;
export const TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID =
`${FLYOUT_TABLE_TEST_ID}HighlightedFieldsOnly` as const;
export const TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID =
`${FLYOUT_TABLE_TEST_ID}HideEmptyFields` as const;
export const TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID =
`${FLYOUT_TABLE_TEST_ID}HideAlertFields` as const;
/* Header */

View file

@ -6,14 +6,21 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DocumentDetailsContext } from '../../shared/context';
import { TABLE_TAB_CONTENT_TEST_ID, TABLE_TAB_SEARCH_INPUT_TEST_ID } from './test_ids';
import { TableTab } from './table_tab';
import { TestProviders } from '../../../../common/mock';
import { mockContextValue } from '../../shared/mocks/mock_context';
import { FLYOUT_TABLE_FIELD_NAME_CELL_ICON_TEST_ID } from '../components/test_ids';
import {
FLYOUT_TABLE_FIELD_NAME_CELL_ICON_TEST_ID,
TABLE_TAB_SETTING_BUTTON_TEST_ID,
TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID,
TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID,
TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID,
} from '../components/test_ids';
import { FLYOUT_STORAGE_KEYS } from '../../shared/constants/local_storage';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
@ -24,14 +31,36 @@ jest.mock('react-redux', () => {
useDispatch: () => mockDispatch,
};
});
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('../../../../common/lib/kibana', () => {
const original = jest.requireActual('../../../../common/lib/kibana');
return {
...original,
useKibana: () => ({
services: {
storage: {
get: mockGet,
set: mockSet,
},
},
}),
};
});
// FLAKY: https://github.com/elastic/kibana/issues/216393
describe.skip('<TableTab />', () => {
describe('<TableTab />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should render table component', () => {
const contextValue = {
eventId: 'some_Id',
browserFields: {},
dataFormattedForFieldBrowser: [],
investigationFields: [],
} as unknown as DocumentDetailsContext;
const { getByTestId } = render(
@ -43,6 +72,7 @@ describe.skip('<TableTab />', () => {
);
expect(getByTestId(TABLE_TAB_CONTENT_TEST_ID)).toBeInTheDocument();
expect(getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID)).toBeInTheDocument();
});
it('should renders the column headers and a field/value pair', () => {
@ -75,4 +105,31 @@ describe.skip('<TableTab />', () => {
expect(queryByText('open')).not.toBeInTheDocument();
expect(queryByTestId(FLYOUT_TABLE_FIELD_NAME_CELL_ICON_TEST_ID)).not.toBeInTheDocument();
});
it('should fetch the table state from local storage', async () => {
mockGet.mockReturnValue({
[FLYOUT_STORAGE_KEYS.TABLE_TAB_STATE]: {
pinnedFields: [],
showHighlightedFields: true,
hideEmptyFields: false,
hideAlertFields: true,
},
});
const { getByTestId } = render(
<TestProviders>
<DocumentDetailsContext.Provider value={mockContextValue}>
<TableTab />
</DocumentDetailsContext.Provider>
</TestProviders>
);
const settingsButton = getByTestId(TABLE_TAB_SETTING_BUTTON_TEST_ID);
act(async () => {
await userEvent.click(settingsButton);
expect(screen.getByTestId(TABLE_TAB_SETTING_HIGHLIGHTED_FIELDS_ONLY_TEST_ID)).toBeChecked();
expect(screen.getByTestId(TABLE_TAB_SETTING_HIDE_EMPTY_FIELDS_TEST_ID)).not.toBeChecked();
expect(screen.getByTestId(TABLE_TAB_SETTING_HIDE_ALERT_FIELDS_TEST_ID)).toBeChecked();
});
});
});

View file

@ -5,45 +5,81 @@
* 2.0.
*/
import React, { memo, useCallback, useMemo, useState } from 'react';
import { getOr, sortBy } from 'lodash/fp';
import React, { memo, useCallback, useMemo, useState, useEffect } from 'react';
import { getOr } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import { css } from '@emotion/react';
import { type EuiBasicTableColumn, EuiText, EuiInMemoryTable, useEuiFontSize } from '@elastic/eui';
import { EuiInMemoryTable, useEuiFontSize, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { dataTableSelectors, tableDefaults } from '@kbn/securitysolution-data-table';
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { FieldSpec } from '@kbn/data-plugin/common';
import { getCategory } from '@kbn/response-ops-alerts-fields-browser/helpers';
import { TableFieldNameCell } from '../components/table_field_name_cell';
import { TableFieldValueCell } from '../components/table_field_value_cell';
import { TABLE_TAB_CONTENT_TEST_ID, TABLE_TAB_SEARCH_INPUT_TEST_ID } from './test_ids';
import { getAllFieldsByName } from '../../../../common/containers/source';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { timelineDefaults } from '../../../../timelines/store/defaults';
import { timelineSelectors } from '../../../../timelines/store';
import type { EventFieldsData } from '../../../../common/components/event_details/types';
import { CellActions } from '../../shared/components/cell_actions';
import { useDocumentDetailsContext } from '../../shared/context';
import { isInTableScope, isTimelineScope } from '../../../../helpers';
import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data';
import { getTableItems } from '../utils/table_tab_utils';
import { TableTabSettingButton } from '../components/table_tab_setting_button';
import { useKibana } from '../../../../common/lib/kibana';
import { FLYOUT_STORAGE_KEYS } from '../../shared/constants/local_storage';
import { getTableTabColumns } from '../utils/table_tab_columns';
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
const COUNT_PER_PAGE_OPTIONS = [25, 50, 100];
const PLACEHOLDER = i18n.translate('xpack.securitySolution.flyout.table.filterPlaceholderLabel', {
defaultMessage: 'Filter by field or value...',
});
export const FIELD = i18n.translate('xpack.securitySolution.flyout.table.fieldCellLabel', {
defaultMessage: 'Field',
});
const VALUE = i18n.translate('xpack.securitySolution.flyout.table.valueCellLabel', {
defaultMessage: 'Value',
});
const PIN_ACTION_CSS = css`
.flyout_table__unPinAction {
opacity: 1;
}
.flyout_table__pinAction {
opacity: 0;
}
&:hover {
.flyout_table__pinAction {
opacity: 1;
}
}
`;
export interface TableTabState {
/**
* The fields that are pinned
*/
pinnedFields: string[];
/**
* Whether to show highlighted fields only
*/
showHighlightedFields: boolean;
/**
* Whether to hide empty fields
*/
hideEmptyFields: boolean;
/**
* Whether to hide alert fields
*/
hideAlertFields: boolean;
}
const DEFAULT_TABLE_TAB_STATE: TableTabState = {
pinnedFields: [],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: false,
};
/**
* Defines the behavior of the search input that appears above the table of data
*/
const search = {
const SEARCH_CONFIG = {
box: {
incremental: true,
placeholder: PLACEHOLDER,
@ -64,91 +100,54 @@ export const getFieldFromBrowserField = memoizeOne(
(newArgs, lastArgs) => newArgs[0] === lastArgs[0]
);
export type ColumnsProvider = (providerOptions: {
/**
* An object containing fields by type
*/
browserFields: BrowserFields;
/**
* Id of the document
*/
eventId: string;
/**
* Maintain backwards compatibility // TODO remove when possible
*/
scopeId: string;
/**
* Id of the rule
*/
ruleId: string;
/**
* Whether the preview link is in preview mode
*/
isPreview: boolean;
/**
* Value of the link field if it exists. Allows to navigate to other pages like host, user, network...
*/
getLinkValue: (field: string) => string | null;
}) => Array<EuiBasicTableColumn<TimelineEventsDetailsItem>>;
export const getColumns: ColumnsProvider = ({
browserFields,
eventId,
scopeId,
getLinkValue,
ruleId,
isPreview,
}) => [
{
field: 'field',
name: (
<EuiText size="xs">
<strong>{FIELD}</strong>
</EuiText>
),
width: '30%',
render: (field, data) => {
return <TableFieldNameCell dataType={(data as EventFieldsData).type} field={field} />;
},
},
{
field: 'values',
name: (
<EuiText size="xs">
<strong>{VALUE}</strong>
</EuiText>
),
width: '70%',
render: (values, data) => {
const fieldFromBrowserField = getFieldFromBrowserField(data.field, browserFields);
return (
<CellActions field={data.field} value={values} isObjectArray={data.isObjectArray}>
<TableFieldValueCell
scopeId={scopeId}
data={data as EventFieldsData}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
getLinkValue={getLinkValue}
ruleId={ruleId}
isPreview={isPreview}
values={values}
/>
</CellActions>
);
},
},
];
/**
* Table view displayed in the document details expandable flyout right section Table tab
*/
export const TableTab = memo(() => {
const smallFontSize = useEuiFontSize('xs').fontSize;
const { euiTheme } = useEuiTheme();
const {
services: { storage },
} = useKibana();
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId, isPreview } =
useDocumentDetailsContext();
const {
browserFields,
dataFormattedForFieldBrowser,
scopeId,
isPreview,
eventId,
investigationFields,
} = useDocumentDetailsContext();
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const highlightedFieldsResult = useHighlightedFields({
dataFormattedForFieldBrowser,
investigationFields,
});
const highlightedFields = useMemo(
() => Object.keys(highlightedFieldsResult),
[highlightedFieldsResult]
);
const [tableTabState, setTableTabState] = useState<TableTabState>(() => {
const restoredTableTabState = storage.get(FLYOUT_STORAGE_KEYS.TABLE_TAB_STATE);
if (restoredTableTabState != null) {
return restoredTableTabState;
}
return DEFAULT_TABLE_TAB_STATE;
});
useEffect(() => {
storage.set(FLYOUT_STORAGE_KEYS.TABLE_TAB_STATE, tableTabState);
}, [tableTabState, storage]);
const renderToolsRight = useCallback(
() => [
<TableTabSettingButton tableTabState={tableTabState} setTableTabState={setTableTabState} />,
],
[tableTabState, setTableTabState]
);
const [pagination, setPagination] = useState<{ pageIndex: number }>({
pageIndex: 0,
});
@ -176,15 +175,33 @@ export const TableTab = memo(() => {
const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const { pinnedFields } = useMemo(() => tableTabState, [tableTabState]);
const onTogglePinned = useCallback(
(field: string, action: 'pin' | 'unpin') => {
if (action === 'pin') {
setTableTabState({
...tableTabState,
pinnedFields: [...pinnedFields, field],
});
} else if (action === 'unpin') {
setTableTabState({
...tableTabState,
pinnedFields: pinnedFields.filter((f) => f !== field),
});
}
},
[pinnedFields, tableTabState, setTableTabState]
);
const items = useMemo(
() =>
sortBy(['field'], dataFormattedForFieldBrowser).map((item, i) => ({
...item,
...fieldsByName[item.field],
valuesConcatenated: item.values != null ? item.values.join() : '',
ariaRowindex: i + 1,
})),
[dataFormattedForFieldBrowser, fieldsByName]
getTableItems({
dataFormattedForFieldBrowser,
fieldsByName,
highlightedFields,
tableTabState,
}),
[dataFormattedForFieldBrowser, highlightedFields, tableTabState, fieldsByName]
);
const getLinkValue = useCallback(
@ -207,23 +224,32 @@ export const TableTab = memo(() => {
({ field }: TimelineEventsDetailsItem) => ({
className: 'flyout-table-row-small-font',
'data-test-subj': `flyout-table-row-${field}`,
...(highlightedFields.includes(field) && {
style: { backgroundColor: euiTheme.colors.backgroundBaseWarning },
}),
css: PIN_ACTION_CSS,
}),
[]
[highlightedFields, euiTheme.colors]
);
const columns = useMemo(
() =>
getColumns({
getTableTabColumns({
browserFields,
eventId,
scopeId,
getLinkValue,
ruleId,
isPreview,
onTogglePinned,
}),
[browserFields, eventId, scopeId, getLinkValue, ruleId, isPreview]
[browserFields, eventId, scopeId, getLinkValue, ruleId, isPreview, onTogglePinned]
);
const search = useMemo(() => {
return { ...SEARCH_CONFIG, toolsRight: renderToolsRight() };
}, [renderToolsRight]);
return (
<EuiInMemoryTable
items={items}

View file

@ -0,0 +1,134 @@
/*
* 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 React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonIcon, EuiText, type EuiBasicTableColumn } from '@elastic/eui';
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import { getFieldFromBrowserField } from '../tabs/table_tab';
import { TableFieldNameCell } from '../components/table_field_name_cell';
import { TableFieldValueCell } from '../components/table_field_value_cell';
import type { EventFieldsData } from '../../../../common/components/event_details/types';
import { CellActions } from '../../shared/components/cell_actions';
import { FLYOUT_TABLE_PIN_ACTION_TEST_ID } from '../components/test_ids';
export const FIELD = i18n.translate('xpack.securitySolution.flyout.table.fieldCellLabel', {
defaultMessage: 'Field',
});
const VALUE = i18n.translate('xpack.securitySolution.flyout.table.valueCellLabel', {
defaultMessage: 'Value',
});
const PIN = i18n.translate('xpack.securitySolution.flyout.table.pinCellLabel', {
defaultMessage: 'Pin',
});
const UNPIN = i18n.translate('xpack.securitySolution.flyout.table.unpinCellLabel', {
defaultMessage: 'Unpin',
});
export type ColumnsProvider = (providerOptions: {
/**
* An object containing fields by type
*/
browserFields: BrowserFields;
/**
* Id of the document
*/
eventId: string;
/**
* Maintain backwards compatibility // TODO remove when possible
*/
scopeId: string;
/**
* Id of the rule
*/
ruleId: string;
/**
* Whether the preview link is in preview mode
*/
isPreview: boolean;
/**
* Value of the link field if it exists. Allows to navigate to other pages like host, user, network...
*/
getLinkValue: (field: string) => string | null;
/**
* Function to toggle pinned fields
*/
onTogglePinned: (field: string, action: 'pin' | 'unpin') => void;
}) => Array<EuiBasicTableColumn<TimelineEventsDetailsItem>>;
/**
* Returns the columns for the table tab
*/
export const getTableTabColumns: ColumnsProvider = ({
browserFields,
eventId,
scopeId,
getLinkValue,
ruleId,
isPreview,
onTogglePinned,
}) => [
{
name: ' ',
field: 'isPinned',
render: (isPinned: boolean, data: TimelineEventsDetailsItem) => {
return (
<EuiButtonIcon
aria-label={isPinned ? UNPIN : PIN}
className={isPinned ? 'flyout_table__unPinAction' : 'flyout_table__pinAction'}
iconType={isPinned ? 'pinFilled' : 'pin'}
color="text"
iconSize="m"
onClick={() => {
onTogglePinned(data.field, isPinned ? 'unpin' : 'pin');
}}
data-test-subj={FLYOUT_TABLE_PIN_ACTION_TEST_ID}
/>
);
},
width: '32px',
},
{
field: 'field',
name: (
<EuiText size="xs">
<strong>{FIELD}</strong>
</EuiText>
),
width: '30%',
render: (field, data) => {
return <TableFieldNameCell dataType={(data as EventFieldsData).type} field={field} />;
},
},
{
field: 'values',
name: (
<EuiText size="xs">
<strong>{VALUE}</strong>
</EuiText>
),
render: (values, data) => {
const fieldFromBrowserField = getFieldFromBrowserField(data.field, browserFields);
return (
<CellActions field={data.field} value={values} isObjectArray={data.isObjectArray}>
<TableFieldValueCell
scopeId={scopeId}
data={data as EventFieldsData}
eventId={eventId}
fieldFromBrowserField={fieldFromBrowserField}
getLinkValue={getLinkValue}
ruleId={ruleId}
isPreview={isPreview}
values={values}
/>
</CellActions>
);
},
},
];

View file

@ -0,0 +1,238 @@
/*
* 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 { getTableItems } from './table_tab_utils';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
const testData = [
{
field: 'b',
values: ['valueb'],
},
{
field: 'kibana.alert.rule.name',
values: ['rule name'],
},
{
field: 'a',
values: ['valuea'],
},
{
field: 'empty',
values: [''],
},
];
const result = {
a: {
name: 'fieldA',
field: 'a',
values: ['valuea'],
valuesConcatenated: 'valuea',
isPinned: false,
ariaRowindex: 1,
},
b: {
name: 'fieldB',
field: 'b',
values: ['valueb'],
valuesConcatenated: 'valueb',
isPinned: false,
ariaRowindex: 2,
},
empty: {
name: 'emptyField',
field: 'empty',
values: [''],
valuesConcatenated: '',
isPinned: false,
ariaRowindex: 3,
},
alert: {
name: 'kibana.alert.rule.name',
field: 'kibana.alert.rule.name',
values: ['rule name'],
valuesConcatenated: 'rule name',
isPinned: false,
ariaRowindex: 4,
},
};
const mockFieldsByName = {
a: { name: 'fieldA' },
b: { name: 'fieldB' },
empty: { name: 'emptyField' },
'kibana.alert.rule.name': { name: 'kibana.alert.rule.name' },
};
describe('getTableItems', () => {
it('should return the table items in alphabetical order', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: [],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([result.a, result.b, result.empty, result.alert]);
});
it('should return only highlighted fields if showHighlightedFields is true', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: ['a', 'b'],
tableTabState: {
pinnedFields: [],
showHighlightedFields: true,
hideEmptyFields: false,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([result.a, result.b]);
});
it('should return pinned fields first', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: ['kibana.alert.rule.name', 'empty'],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([
{ ...result.empty, isPinned: true },
{ ...result.alert, isPinned: true },
result.a,
result.b,
]);
});
it('should return correct items when there are pinned fields and showHighlightedFields is true', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: ['a', 'b', 'kibana.alert.rule.name'],
tableTabState: {
pinnedFields: ['b', 'empty'],
showHighlightedFields: true,
hideEmptyFields: false,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([{ ...result.b, isPinned: true }, result.a, result.alert]);
});
describe('hideEmptyFields', () => {
it('should hide empty fields if hideEmptyFields is true', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: [],
showHighlightedFields: false,
hideEmptyFields: true,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([result.a, result.b, result.alert]);
});
it('should hide empty fields correctly for highlighted fields', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: ['a', 'empty'],
tableTabState: {
pinnedFields: [],
showHighlightedFields: true,
hideEmptyFields: true,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([result.a]);
});
it('should hide empty fields correctly for pinned fields', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: ['b', 'kibana.alert.rule.name', 'empty'],
showHighlightedFields: false,
hideEmptyFields: true,
hideAlertFields: false,
},
});
expect(tableItems).toEqual([
{ ...result.b, isPinned: true },
{ ...result.alert, isPinned: true },
result.a,
]);
});
});
describe('hideAlertFields', () => {
it('should hide alert fields if hideAlertFields is true', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: ['a'],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: true,
},
});
expect(tableItems).toEqual([
{ ...result.a, isPinned: true },
{ ...result.b, isPinned: false },
result.empty,
]);
});
it('should hide alert fields correctly for highlighted fields', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: ['a', 'kibana.alert.rule.name'],
tableTabState: {
pinnedFields: [],
showHighlightedFields: true,
hideEmptyFields: false,
hideAlertFields: true,
},
});
expect(tableItems).toEqual([result.a]);
});
it('should hide alert fields correctly for pinned fields', () => {
const tableItems = getTableItems({
dataFormattedForFieldBrowser: testData as unknown as TimelineEventsDetailsItem[],
fieldsByName: mockFieldsByName,
highlightedFields: [],
tableTabState: {
pinnedFields: ['a', 'kibana.alert.rule.name'],
showHighlightedFields: false,
hideEmptyFields: false,
hideAlertFields: true,
},
});
expect(tableItems).toEqual([{ ...result.a, isPinned: true }, result.b, result.empty]);
});
});
});

View file

@ -0,0 +1,89 @@
/*
* 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 { sortBy } from 'lodash/fp';
import { ALERT_NAMESPACE } from '@kbn/rule-data-utils';
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
import type { FieldSpec } from '@kbn/data-plugin/common';
import type { TableTabState } from '../tabs/table_tab';
interface ItemsEntry {
pinnedRows: TimelineEventsDetailsItem[];
restRows: TimelineEventsDetailsItem[];
}
interface GetTableItemsProps {
/**
* Array of data formatted for field browser
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[];
/**
* Object of fields by name
*/
fieldsByName: { [fieldName: string]: Partial<FieldSpec> };
/**
* Array of highlighted fields
*/
highlightedFields: string[];
/**
* Table tab state
*/
tableTabState: TableTabState;
}
export const getTableItems: (props: GetTableItemsProps) => TimelineEventsDetailsItem[] = ({
dataFormattedForFieldBrowser,
fieldsByName,
highlightedFields,
tableTabState,
}: GetTableItemsProps) => {
const { pinnedFields, showHighlightedFields, hideEmptyFields, hideAlertFields } = tableTabState;
const pinnedFieldsSet = new Set(pinnedFields);
const sortedFields = sortBy(['field'], dataFormattedForFieldBrowser).map((item, i) => ({
...item,
...fieldsByName[item.field],
valuesConcatenated: item.values != null ? item.values.join() : '',
ariaRowindex: i + 1,
isPinned: pinnedFieldsSet.has(item.field),
}));
const { pinnedRows, restRows } = sortedFields.reduce<ItemsEntry>(
(acc, curField) => {
// Hide empty fields
if (hideEmptyFields && curField.valuesConcatenated === '') {
return acc;
}
// Hide alert fields
if (
hideAlertFields &&
(curField.field.startsWith(ALERT_NAMESPACE) || curField.field.startsWith('signal.'))
) {
return acc;
}
// Process highlighted fields
if (showHighlightedFields && !highlightedFields.includes(curField.field)) {
return acc;
}
// Process pinned fields when showHighlightedFields is false
if (curField.isPinned) {
acc.pinnedRows.push(curField);
} else {
acc.restRows.push(curField);
}
return acc;
},
{
pinnedRows: [],
restRows: [],
}
);
return [...pinnedRows, ...restRows];
};

View file

@ -9,4 +9,5 @@ export const FLYOUT_STORAGE_KEYS = {
OVERVIEW_TAB_EXPANDED_SECTIONS:
'securitySolution.documentDetailsFlyout.overviewSectionExpanded.v8.14',
RIGHT_PANEL_SELECTED_TABS: 'securitySolution.documentDetailsFlyout.rightPanel.selectedTabs.v8.14',
TABLE_TAB_STATE: 'securitySolution.documentDetailsFlyout.tableTabState.v8.19',
};