mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
e9d0769a3d
commit
4b47481566
15 changed files with 445 additions and 214 deletions
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue