[Security Solution] [Timeline] Fields browser add a view all / selected option (#128049)

* view selected option added

* new header component

* test fixed

* Update x-pack/plugins/timelines/public/components/t_grid/toolbar/fields_browser/field_table_header.test.tsx

use not.toBeInTheDocument

Co-authored-by: Pablo Machado <machadoum@gmail.com>

* pass callback down instead of state setter

Co-authored-by: Pablo Machado <machadoum@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-03-22 17:04:51 +01:00 committed by GitHub
parent e9d0769a3d
commit 4b47481566
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 445 additions and 214 deletions

View file

@ -9,6 +9,7 @@ import {
FIELDS_BROWSER_CHECKBOX,
FIELDS_BROWSER_CONTAINER,
FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES,
FIELDS_BROWSER_VIEW_BUTTON,
} from '../../screens/fields_browser';
import {
HOST_GEO_CITY_NAME_HEADER,
@ -18,9 +19,10 @@ import {
} from '../../screens/hosts/events';
import {
activateViewAll,
activateViewSelected,
closeFieldsBrowser,
filterFieldsBrowser,
toggleCategory,
} from '../../tasks/fields_browser';
import { loginAndWaitForPage } from '../../tasks/login';
import { openEvents } from '../../tasks/hosts/main';
@ -64,16 +66,20 @@ describe('Events Viewer', () => {
cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist');
});
it('displays "view all" option by default', () => {
cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all');
});
it('displays all categories (by default)', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty');
});
it('displays a checked checkbox for all of the default events viewer columns that are also in the default ECS category', () => {
const category = 'default ECS';
toggleCategory(category);
it('displays only the default selected fields when "view selected" option is enabled', () => {
activateViewSelected();
defaultHeadersInDefaultEcsCategory.forEach((header) =>
cy.get(FIELDS_BROWSER_CHECKBOX(header.id)).should('be.checked')
);
activateViewAll();
});
});

View file

@ -15,6 +15,7 @@ import {
FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER,
FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES,
FIELDS_BROWSER_CATEGORY_BADGE,
FIELDS_BROWSER_VIEW_BUTTON,
} from '../../screens/fields_browser';
import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline';
import { cleanKibana } from '../../tasks/common';
@ -29,6 +30,8 @@ import {
removesMessageField,
resetFields,
toggleCategory,
activateViewSelected,
activateViewAll,
} from '../../tasks/fields_browser';
import { loginAndWaitForPage } from '../../tasks/login';
import { openTimelineUsingToggle } from '../../tasks/security_main';
@ -65,6 +68,10 @@ describe('Fields Browser', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty');
});
it('displays "view all" option by default', () => {
cy.get(FIELDS_BROWSER_VIEW_BUTTON).should('contain.text', 'View: all');
});
it('displays the expected count of categories that match the filter input', () => {
const filterInput = 'host.mac';
@ -80,15 +87,13 @@ describe('Fields Browser', () => {
cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2');
});
it('the `default ECS` category matches the default timeline header fields', () => {
const category = 'default ECS';
toggleCategory(category);
it('displays only the selected fields when "view selected" option is enabled', () => {
activateViewSelected();
cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`);
defaultHeaders.forEach((header) => {
cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked');
});
toggleCategory(category);
activateViewAll();
});
it('creates the category badge when it is selected', () => {

View file

@ -17,6 +17,11 @@ export const FIELDS_BROWSER_FIELDS_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-te
export const FIELDS_BROWSER_FILTER_INPUT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-search"]`;
export const FIELDS_BROWSER_VIEW_BUTTON = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="viewSelectorButton"]`;
export const FIELDS_BROWSER_VIEW_MENU = '[data-test-subj="viewSelectorMenu"]';
export const FIELDS_BROWSER_VIEW_ALL = `${FIELDS_BROWSER_VIEW_MENU} [data-test-subj="viewSelectorOption-all"]`;
export const FIELDS_BROWSER_VIEW_SELECTED = `${FIELDS_BROWSER_VIEW_MENU} [data-test-subj="viewSelectorOption-selected"]`;
export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_CHECKBOX = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-host.geo.city_name-checkbox"]`;
export const FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER =

View file

@ -16,6 +16,9 @@ import {
FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON,
FIELDS_BROWSER_CATEGORY_FILTER_OPTION,
FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH,
FIELDS_BROWSER_VIEW_ALL,
FIELDS_BROWSER_VIEW_BUTTON,
FIELDS_BROWSER_VIEW_SELECTED,
} from '../screens/fields_browser';
export const addsFields = (fields: string[]) => {
@ -74,3 +77,12 @@ export const removesMessageField = () => {
export const resetFields = () => {
cy.get(FIELDS_BROWSER_RESET_FIELDS).click({ force: true });
};
export const activateViewSelected = () => {
cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true });
cy.get(FIELDS_BROWSER_VIEW_SELECTED).click({ force: true });
};
export const activateViewAll = () => {
cy.get(FIELDS_BROWSER_VIEW_BUTTON).click({ force: true });
cy.get(FIELDS_BROWSER_VIEW_ALL).click({ force: true });
};

View file

@ -53,6 +53,3 @@ export const defaultHeaders: ColumnHeaderOptions[] = [
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
];
/** The default category of fields shown in the Timeline */
export const DEFAULT_CATEGORY_NAME = 'default ECS';

View file

@ -12,7 +12,7 @@ import { TestProviders, mockBrowserFields, defaultHeaders } from '../../../../mo
import { mockGlobalState } from '../../../../mock/global_state';
import { tGridActions } from '../../../../store/t_grid';
import { FieldsBrowser } from './field_browser';
import { FieldsBrowser, FieldsBrowserComponentProps } from './field_browser';
import { createStore, State } from '../../../../types';
import { createSecuritySolutionStorageMock } from '../../../../mock/mock_local_storage';
@ -27,9 +27,8 @@ jest.mock('react-redux', () => {
});
const timelineId = 'test';
const onHide = jest.fn();
const testProps = {
const testProps: FieldsBrowserComponentProps = {
columnHeaders: [],
browserFields: mockBrowserFields,
filteredBrowserFields: mockBrowserFields,
searchInput: '',
appliedFilterInput: '',
@ -40,6 +39,8 @@ const testProps = {
restoreFocusTo: React.createRef<HTMLButtonElement>(),
selectedCategoryIds: [],
timelineId,
filterSelectedEnabled: false,
onFilterSelectedChange: jest.fn(),
};
const { storage } = createSecuritySolutionStorageMock();

View file

@ -33,7 +33,10 @@ import { CategoriesSelector } from './categories_selector';
import { FieldTable } from './field_table';
import { CategoriesBadges } from './categories_badges';
type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width' | 'options'> & {
export type FieldsBrowserComponentProps = Pick<
FieldBrowserProps,
'timelineId' | 'width' | 'options'
> & {
/**
* The current timeline column headers
*/
@ -44,6 +47,9 @@ type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width' |
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/** when true, show only the the selected field */
filterSelectedEnabled: boolean;
onFilterSelectedChange: (enabled: boolean) => void;
/**
* When true, a busy spinner will be shown to indicate the field browser
* is searching for fields that match the specified `searchInput`
@ -83,17 +89,19 @@ type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width' |
* This component has no internal state, but it uses lifecycle methods to
* set focus to the search input, scroll to the selected category, etc
*/
const FieldsBrowserComponent: React.FC<Props> = ({
const FieldsBrowserComponent: React.FC<FieldsBrowserComponentProps> = ({
appliedFilterInput,
columnHeaders,
filteredBrowserFields,
filterSelectedEnabled,
isSearching,
onFilterSelectedChange,
setSelectedCategoryIds,
onSearchInputChange,
onHide,
options,
restoreFocusTo,
searchInput,
appliedFilterInput,
selectedCategoryIds,
timelineId,
width = FIELD_BROWSER_WIDTH,
@ -182,8 +190,10 @@ const FieldsBrowserComponent: React.FC<Props> = ({
timelineId={timelineId}
columnHeaders={columnHeaders}
filteredBrowserFields={filteredBrowserFields}
filterSelectedEnabled={filterSelectedEnabled}
searchInput={appliedFilterInput}
selectedCategoryIds={selectedCategoryIds}
onFilterSelectedChange={onFilterSelectedChange}
getFieldTableColumns={getFieldTableColumns}
onHide={onHide}
/>

View file

@ -46,16 +46,14 @@ const defaultProps: FieldTableProps = {
filteredBrowserFields: {},
searchInput: '',
timelineId,
filterSelectedEnabled: false,
onFilterSelectedChange: jest.fn(),
onHide: jest.fn(),
};
describe('FieldTable', () => {
const timestampField = mockBrowserFields.base.fields![timestampFieldId];
const defaultPageSize = 10;
const totalFields = Object.values(mockBrowserFields).reduce(
(total, { fields }) => total + Object.keys(fields ?? {}).length,
0
);
beforeEach(() => {
mockDispatch.mockClear();
@ -69,7 +67,6 @@ describe('FieldTable', () => {
);
expect(result.getByText('No items found')).toBeInTheDocument();
expect(result.getByTestId('fields-count').textContent).toContain('0');
});
it('should render field table with fields of all categories', () => {
@ -80,7 +77,6 @@ describe('FieldTable', () => {
);
expect(result.container.getElementsByClassName('euiTableRow').length).toBe(defaultPageSize);
expect(result.getByTestId('fields-count').textContent).toContain(totalFields);
});
it('should render field table with fields of categories selected', () => {
@ -103,7 +99,6 @@ describe('FieldTable', () => {
);
expect(result.container.getElementsByClassName('euiTableRow').length).toBe(fieldCount);
expect(result.getByTestId('fields-count').textContent).toContain(fieldCount);
});
it('should render field table with custom columns', () => {
@ -125,7 +120,6 @@ describe('FieldTable', () => {
</TestProviders>
);
expect(result.getByTestId('fields-count').textContent).toContain(totalFields);
expect(result.getAllByText('Custom column').length).toBeGreaterThan(0);
expect(result.getAllByTestId('customColumn').length).toEqual(defaultPageSize);
});

View file

@ -7,14 +7,14 @@
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { EuiInMemoryTable, EuiText } from '@elastic/eui';
import { EuiInMemoryTable } from '@elastic/eui';
import { useDispatch } from 'react-redux';
import { BrowserFields, ColumnHeaderOptions } from '../../../../../common';
import * as i18n from './translations';
import { getColumnHeader, getFieldColumns, getFieldItems, isActionsColumn } from './field_items';
import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers';
import { tGridActions } from '../../../../store/t_grid';
import type { GetFieldTableColumns } from '../../../../../common/types/fields_browser';
import { FieldTableHeader } from './field_table_header';
export interface FieldTableProps {
timelineId: string;
@ -25,6 +25,9 @@ export interface FieldTableProps {
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/** when true, show only the the selected field */
filterSelectedEnabled: boolean;
onFilterSelectedChange: (enabled: boolean) => void;
/**
* Optional function to customize field table columns
*/
@ -58,9 +61,11 @@ Count.displayName = 'Count';
const FieldTableComponent: React.FC<FieldTableProps> = ({
columnHeaders,
filteredBrowserFields,
filterSelectedEnabled,
getFieldTableColumns,
searchInput,
selectedCategoryIds,
onFilterSelectedChange,
timelineId,
onHide,
}) => {
@ -106,13 +111,13 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
return (
<>
<EuiText data-test-subj="fields-showing" size="xs">
{i18n.FIELDS_SHOWING}
<Count data-test-subj="fields-count"> {fieldItems.length} </Count>
{i18n.FIELDS_COUNT(fieldItems.length)}
</EuiText>
<FieldTableHeader
fieldCount={fieldItems.length}
filterSelectedEnabled={filterSelectedEnabled}
onFilterSelectedChange={onFilterSelectedChange}
/>
<TableContainer className="euiTable--compressed" height={TABLE_HEIGHT}>
<TableContainer height={TABLE_HEIGHT}>
<EuiInMemoryTable
data-test-subj="field-table"
className={`${CATEGORY_TABLE_CLASS_NAME} eui-yScroll`}
@ -122,6 +127,7 @@ const FieldTableComponent: React.FC<FieldTableProps> = ({
pagination={true}
sorting={true}
hasActions={hasActions}
compressed
/>
</TableContainer>
</>

View file

@ -0,0 +1,119 @@
/*
* 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 } from '@testing-library/react';
import { TestProviders } from '../../../../mock';
import { FieldTableHeader, FieldTableHeaderProps } from './field_table_header';
const mockOnFilterSelectedChange = jest.fn();
const defaultProps: FieldTableHeaderProps = {
fieldCount: 0,
filterSelectedEnabled: false,
onFilterSelectedChange: mockOnFilterSelectedChange,
};
describe('FieldTableHeader', () => {
describe('FieldCount', () => {
it('should render empty field table', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} />
</TestProviders>
);
expect(result.getByTestId('fields-showing').textContent).toBe('Showing 0 fields');
});
it('should render field table with one singular field count value', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} fieldCount={1} />
</TestProviders>
);
expect(result.getByTestId('fields-showing').textContent).toBe('Showing 1 field');
});
it('should render field table with multiple fields count value', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} fieldCount={4} />
</TestProviders>
);
expect(result.getByTestId('fields-showing').textContent).toBe('Showing 4 fields');
});
});
describe('View selected', () => {
beforeEach(() => {
mockOnFilterSelectedChange.mockClear();
});
it('should render "view all" option when filterSelected is not enabled', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} filterSelectedEnabled={false} />
</TestProviders>
);
expect(result.getByTestId('viewSelectorButton').textContent).toBe('View: all');
});
it('should render "view selected" option when filterSelected is not enabled', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} filterSelectedEnabled={true} />
</TestProviders>
);
expect(result.getByTestId('viewSelectorButton').textContent).toBe('View: selected');
});
it('should open the view selector with button click', async () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} />
</TestProviders>
);
expect(result.queryByTestId('viewSelectorMenu')).not.toBeInTheDocument();
expect(result.queryByTestId('viewSelectorOption-all')).not.toBeInTheDocument();
expect(result.queryByTestId('viewSelectorOption-selected')).not.toBeInTheDocument();
result.getByTestId('viewSelectorButton').click();
expect(result.getByTestId('viewSelectorMenu')).toBeInTheDocument();
expect(result.getByTestId('viewSelectorOption-all')).toBeInTheDocument();
expect(result.getByTestId('viewSelectorOption-selected')).toBeInTheDocument();
});
it('should callback when "view all" option is clicked', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} filterSelectedEnabled={false} />
</TestProviders>
);
result.getByTestId('viewSelectorButton').click();
result.getByTestId('viewSelectorOption-all').click();
expect(mockOnFilterSelectedChange).toHaveBeenCalledWith(false);
});
it('should callback when "view selected" option is clicked', () => {
const result = render(
<TestProviders>
<FieldTableHeader {...defaultProps} filterSelectedEnabled={false} />
</TestProviders>
);
result.getByTestId('viewSelectorButton').click();
result.getByTestId('viewSelectorOption-selected').click();
expect(mockOnFilterSelectedChange).toHaveBeenCalledWith(true);
});
});
});

View file

@ -0,0 +1,112 @@
/*
* 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, { useCallback, useState } from 'react';
import styled from 'styled-components';
import {
EuiText,
EuiPopover,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiHorizontalRule,
} from '@elastic/eui';
import * as i18n from './translations';
export interface FieldTableHeaderProps {
fieldCount: number;
filterSelectedEnabled: boolean;
onFilterSelectedChange: (enabled: boolean) => void;
}
const Count = styled.span`
font-weight: bold;
`;
Count.displayName = 'Count';
const FieldTableHeaderComponent: React.FC<FieldTableHeaderProps> = ({
fieldCount,
filterSelectedEnabled,
onFilterSelectedChange,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => {
setIsPopoverOpen((open) => !open);
}, []);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiText data-test-subj="fields-showing" size="xs">
{i18n.FIELDS_SHOWING}
<Count data-test-subj="fields-count"> {fieldCount} </Count>
{i18n.FIELDS_COUNT(fieldCount)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiPopover
panelPaddingSize="none"
anchorPosition="downRight"
isOpen={isPopoverOpen}
closePopover={closePopover}
button={
<EuiButtonEmpty
data-test-subj="viewSelectorButton"
size="xs"
iconType="arrowDown"
iconSide="right"
onClick={togglePopover}
>
{`${i18n.VIEW_LABEL}: ${
filterSelectedEnabled ? i18n.VIEW_VALUE_SELECTED : i18n.VIEW_VALUE_ALL
}`}
</EuiButtonEmpty>
}
>
<EuiContextMenuPanel
data-test-subj="viewSelectorMenu"
size="s"
items={[
<EuiContextMenuItem
data-test-subj="viewSelectorOption-all"
key="viewAll"
icon={filterSelectedEnabled ? 'empty' : 'check'}
onClick={() => {
onFilterSelectedChange(false);
closePopover();
}}
>
{`${i18n.VIEW_LABEL} ${i18n.VIEW_VALUE_ALL}`}
</EuiContextMenuItem>,
<EuiHorizontalRule key="separator" margin="none" />,
<EuiContextMenuItem
data-test-subj="viewSelectorOption-selected"
key="viewSelected"
icon={filterSelectedEnabled ? 'check' : 'empty'}
onClick={() => {
onFilterSelectedChange(true);
closePopover();
}}
>
{`${i18n.VIEW_LABEL} ${i18n.VIEW_VALUE_SELECTED}`}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
);
};
export const FieldTableHeader = React.memo(FieldTableHeaderComponent);

View file

@ -9,11 +9,12 @@ import { mockBrowserFields } from '../../../../mock';
import {
categoryHasFields,
createVirtualCategory,
getFieldCount,
filterBrowserFieldsByFieldName,
filterSelectedBrowserFields,
} from './helpers';
import { BrowserFields } from '../../../../../common/search_strategy';
import { ColumnHeaderOptions } from '../../../../../common';
describe('helpers', () => {
describe('categoryHasFields', () => {
@ -255,144 +256,83 @@ describe('helpers', () => {
});
});
describe('createVirtualCategory', () => {
test('it combines the specified fields into a virtual category when the input ONLY contains field names that contain dots (e.g. agent.hostname)', () => {
const expectedMatchingFields = {
fields: {
'agent.hostname': {
aggregatable: true,
category: 'agent',
description: null,
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'agent.hostname',
searchable: true,
type: 'string',
},
'client.domain': {
aggregatable: true,
category: 'client',
description: 'Client domain.',
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'client.domain',
searchable: true,
type: 'string',
},
'client.geo.country_iso_code': {
aggregatable: true,
category: 'client',
description: 'Country ISO code.',
example: 'CA',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'client.geo.country_iso_code',
searchable: true,
type: 'string',
},
},
};
describe('filterSelectedBrowserFields', () => {
const columnHeaders = [
{ id: 'agent.ephemeral_id' },
{ id: 'agent.id' },
{ id: 'container.id' },
] as ColumnHeaderOptions[];
const fieldIds = ['agent.hostname', 'client.domain', 'client.geo.country_iso_code'];
expect(
createVirtualCategory({
browserFields: mockBrowserFields,
fieldIds,
})
).toEqual(expectedMatchingFields);
test('it returns an empty collection when browserFields is empty', () => {
expect(filterSelectedBrowserFields({ browserFields: {}, columnHeaders: [] })).toEqual({});
});
test('it combines the specified fields into a virtual category when the input includes field names from the base category that do NOT contain dots (e.g. @timestamp)', () => {
const expectedMatchingFields = {
fields: {
'agent.hostname': {
aggregatable: true,
category: 'agent',
description: null,
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'agent.hostname',
searchable: true,
type: 'string',
},
'@timestamp': {
aggregatable: true,
category: 'base',
description:
'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.',
example: '2016-05-23T08:05:34.853Z',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: '@timestamp',
searchable: true,
type: 'date',
},
'client.domain': {
aggregatable: true,
category: 'client',
description: 'Client domain.',
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'client.domain',
searchable: true,
type: 'string',
},
},
};
const fieldIds = ['agent.hostname', '@timestamp', 'client.domain'];
expect(
createVirtualCategory({
browserFields: mockBrowserFields,
fieldIds,
})
).toEqual(expectedMatchingFields);
test('it returns an empty collection when browserFields is empty and columnHeaders is non empty', () => {
expect(filterSelectedBrowserFields({ browserFields: {}, columnHeaders })).toEqual({});
});
test('it combines the specified fields into a virtual category omitting the fields missing in the browser fields', () => {
const expectedMatchingFields = {
fields: {
'@timestamp': {
aggregatable: true,
category: 'base',
description:
'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.',
example: '2016-05-23T08:05:34.853Z',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: '@timestamp',
searchable: true,
type: 'date',
test('it returns an empty collection when browserFields is NOT empty and columnHeaders is empty', () => {
expect(
filterSelectedBrowserFields({
browserFields: mockBrowserFields,
columnHeaders: [],
})
).toEqual({});
});
test('it returns (only) non-empty categories, where each category contains only the fields matching the substring', () => {
const filtered: BrowserFields = {
agent: {
fields: {
'agent.ephemeral_id': {
aggregatable: true,
category: 'agent',
description:
'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.',
example: '8a4f500f',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'agent.ephemeral_id',
searchable: true,
type: 'string',
},
'agent.id': {
aggregatable: true,
category: 'agent',
description:
'Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.',
example: '8a4f500d',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'agent.id',
searchable: true,
type: 'string',
},
},
'client.domain': {
aggregatable: true,
category: 'client',
description: 'Client domain.',
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'client.domain',
searchable: true,
type: 'string',
},
container: {
fields: {
'container.id': {
aggregatable: true,
category: 'container',
description: 'Unique container id.',
example: null,
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'container.id',
searchable: true,
type: 'string',
},
},
},
};
const fieldIds = ['agent.hostname', '@timestamp', 'client.domain'];
const { agent, ...mockBrowserFieldsWithoutAgent } = mockBrowserFields;
expect(
createVirtualCategory({
browserFields: mockBrowserFieldsWithoutAgent,
fieldIds,
filterSelectedBrowserFields({
browserFields: mockBrowserFields,
columnHeaders,
})
).toEqual(expectedMatchingFields);
).toEqual(filtered);
});
});
});

View file

@ -6,13 +6,13 @@
*/
import { EuiBadge, EuiLoadingSpinner } from '@elastic/eui';
import { filter, get, pickBy } from 'lodash/fp';
import { pickBy } from 'lodash/fp';
import styled from 'styled-components';
import { TimelineId } from '../../../../../public/types';
import type { BrowserField, BrowserFields } from '../../../../../common/search_strategy';
import { defaultHeaders } from '../../../../store/t_grid/defaults';
import { DEFAULT_CATEGORY_NAME } from '../../body/column_headers/default_headers';
import { ColumnHeaderOptions } from '../../../../../common';
export const LoadingSpinner = styled(EuiLoadingSpinner)`
cursor: pointer;
@ -45,6 +45,9 @@ export const filterBrowserFieldsByFieldName = ({
substring: string;
}): BrowserFields => {
const trimmedSubstring = substring.trim();
if (trimmedSubstring === '') {
return browserFields;
}
// filter each category such that it only contains fields with field names
// that contain the specified substring:
@ -53,11 +56,10 @@ export const filterBrowserFieldsByFieldName = ({
...filteredCategories,
[categoryId]: {
...browserFields[categoryId],
fields: filter(
(f) => f.name != null && f.name.includes(trimmedSubstring),
fields: pickBy(
({ name }) => name != null && name.includes(trimmedSubstring),
browserFields[categoryId].fields
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
).reduce((filtered, field) => ({ ...filtered, [field.name!]: field }), {}),
),
},
}),
{}
@ -73,46 +75,40 @@ export const filterBrowserFieldsByFieldName = ({
};
/**
* Returns a "virtual" category (e.g. default ECS) from the specified fieldIds
* Filters the selected `BrowserFields` to return a new collection where every
* category contains at least one field that is present in the `columnHeaders`.
*/
export const createVirtualCategory = ({
export const filterSelectedBrowserFields = ({
browserFields,
fieldIds,
columnHeaders,
}: {
browserFields: BrowserFields;
fieldIds: string[];
}): Partial<BrowserField> => ({
fields: fieldIds.reduce<Readonly<BrowserFields>>((fields, fieldId) => {
const splitId = fieldId.split('.'); // source.geo.city_name -> [source, geo, city_name]
const browserField = get(
[splitId.length > 1 ? splitId[0] : 'base', 'fields', fieldId],
browserFields
);
columnHeaders: ColumnHeaderOptions[];
}): BrowserFields => {
const selectedFieldIds = new Set(columnHeaders.map(({ id }) => id));
return {
...fields,
...(browserField
? {
[fieldId]: {
...browserField,
name: fieldId,
},
}
: {}),
};
}, {}),
});
const filteredBrowserFields: BrowserFields = Object.keys(browserFields).reduce(
(filteredCategories, categoryId) => ({
...filteredCategories,
[categoryId]: {
...browserFields[categoryId],
fields: pickBy(
({ name }) => name != null && selectedFieldIds.has(name),
browserFields[categoryId].fields
),
},
}),
{}
);
/** Merges the specified browser fields with the default category (i.e. `default ECS`) */
export const mergeBrowserFieldsWithDefaultCategory = (
browserFields: BrowserFields
): BrowserFields => ({
...browserFields,
[DEFAULT_CATEGORY_NAME]: createVirtualCategory({
browserFields,
fieldIds: defaultHeaders.map((header) => header.id),
}),
});
// only pick non-empty categories from the filtered browser fields
const nonEmptyCategories: BrowserFields = pickBy(
(category) => categoryHasFields(category),
filteredBrowserFields
);
return nonEmptyCategories;
};
export const getAlertColumnHeader = (timelineId: string, fieldId: string) =>
timelineId === TimelineId.detectionsPage || timelineId === TimelineId.detectionsRulesDetailsPage

View file

@ -13,7 +13,7 @@ import styled from 'styled-components';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import type { FieldBrowserProps } from '../../../../../common/types/fields_browser';
import { FieldsBrowser } from './field_browser';
import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers';
import { filterBrowserFieldsByFieldName, filterSelectedBrowserFields } from './helpers';
import * as i18n from './translations';
const FIELDS_BUTTON_CLASS_NAME = 'fields-button';
@ -44,6 +44,8 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
const [appliedFilterInput, setAppliedFilterInput] = useState('');
/** all fields in this collection have field names that match the filterInput */
const [filteredBrowserFields, setFilteredBrowserFields] = useState<BrowserFields | null>(null);
/** when true, show only the the selected field */
const [filterSelectedEnabled, setFilterSelectedEnabled] = useState(false);
/** when true, show a spinner in the input to indicate the field browser is searching for matching field names */
const [isSearching, setIsSearching] = useState(false);
/** this category will be displayed in the right-hand pane of the field browser */
@ -67,14 +69,23 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
};
}, [debouncedApplyFilterInput]);
const selectionFilteredBrowserFields = useMemo<BrowserFields>(
() =>
filterSelectedEnabled
? filterSelectedBrowserFields({ browserFields, columnHeaders })
: browserFields,
[browserFields, columnHeaders, filterSelectedEnabled]
);
useEffect(() => {
const newFilteredBrowserFields = filterBrowserFieldsByFieldName({
browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields),
substring: appliedFilterInput,
});
setFilteredBrowserFields(newFilteredBrowserFields);
setFilteredBrowserFields(
filterBrowserFieldsByFieldName({
browserFields: selectionFilteredBrowserFields,
substring: appliedFilterInput,
})
);
setIsSearching(false);
}, [appliedFilterInput, browserFields]);
}, [appliedFilterInput, selectionFilteredBrowserFields]);
/** Shows / hides the field browser */
const onShow = useCallback(() => {
@ -86,6 +97,7 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
setFilterInput('');
setAppliedFilterInput('');
setFilteredBrowserFields(null);
setFilterSelectedEnabled(false);
setIsSearching(false);
setSelectedCategoryIds([]);
setShow(false);
@ -101,10 +113,13 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
[debouncedApplyFilterInput]
);
// only merge in the default category if the field browser is visible
const browserFieldsWithDefaultCategory = useMemo(() => {
return show ? mergeBrowserFieldsWithDefaultCategory(browserFields) : {};
}, [show, browserFields]);
/** Invoked when the user changes the view all/selected value */
const onFilterSelectedChange = useCallback(
(filterSelected: boolean) => {
setFilterSelectedEnabled(filterSelected);
},
[setFilterSelectedEnabled]
);
return (
<FieldsBrowserButtonContainer data-test-subj="fields-browser-button-container">
@ -125,13 +140,14 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
{show && (
<FieldsBrowser
browserFields={browserFieldsWithDefaultCategory}
columnHeaders={columnHeaders}
filteredBrowserFields={
filteredBrowserFields != null ? filteredBrowserFields : browserFieldsWithDefaultCategory
filteredBrowserFields != null ? filteredBrowserFields : browserFields
}
filterSelectedEnabled={filterSelectedEnabled}
isSearching={isSearching}
setSelectedCategoryIds={setSelectedCategoryIds}
onFilterSelectedChange={onFilterSelectedChange}
onHide={onHide}
onSearchInputChange={updateFilter}
options={options}

View file

@ -88,3 +88,15 @@ export const VIEW_COLUMN = (field: string) =>
values: { field },
defaultMessage: 'View {field} column',
});
export const VIEW_LABEL = i18n.translate('xpack.timelines.fieldBrowser.viewLabel', {
defaultMessage: 'View',
});
export const VIEW_VALUE_SELECTED = i18n.translate('xpack.timelines.fieldBrowser.viewSelected', {
defaultMessage: 'selected',
});
export const VIEW_VALUE_ALL = i18n.translate('xpack.timelines.fieldBrowser.viewAll', {
defaultMessage: 'all',
});