[SecuritySolution] Alerts table Fields Browser revamp (#126105)

* field browser first revamp implementation

* customize columns for security solution alert tables

* cleaning

* some tests

* clean unused code

* field browser tests created and existing fixed

* security solution test fixes

* translations cleaned

* fix test

* adapt cypress tests

* remove translation

* fix typo

* remove duplicated test

* type error fixed

* enable body vertical scroll for small screens

* fix new field not added to the table bug

* addapt Kevin performance improvement

* fixed linter error

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Sergi Massaneda 2022-03-07 15:14:41 +01:00 committed by GitHub
parent 90f0d8de01
commit a79562a67e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 1701 additions and 2818 deletions

View file

@ -8,7 +8,7 @@
import {
FIELDS_BROWSER_CHECKBOX,
FIELDS_BROWSER_CONTAINER,
FIELDS_BROWSER_SELECTED_CATEGORY_TITLE,
FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES,
} from '../../screens/fields_browser';
import {
HOST_GEO_CITY_NAME_HEADER,
@ -17,7 +17,11 @@ import {
SERVER_SIDE_EVENT_COUNT,
} from '../../screens/hosts/events';
import { closeFieldsBrowser, filterFieldsBrowser } from '../../tasks/fields_browser';
import {
closeFieldsBrowser,
filterFieldsBrowser,
toggleCategory,
} from '../../tasks/fields_browser';
import { loginAndWaitForPage } from '../../tasks/login';
import { openEvents } from '../../tasks/hosts/main';
import {
@ -60,11 +64,13 @@ describe('Events Viewer', () => {
cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist');
});
it('displays the `default ECS` category (by default)', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', 'default ECS');
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);
defaultHeadersInDefaultEcsCategory.forEach((header) =>
cy.get(FIELDS_BROWSER_CHECKBOX(header.id)).should('be.checked')
);

View file

@ -8,14 +8,13 @@
import {
FIELDS_BROWSER_CATEGORIES_COUNT,
FIELDS_BROWSER_FIELDS_COUNT,
FIELDS_BROWSER_HOST_CATEGORIES_COUNT,
FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER,
FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER,
FIELDS_BROWSER_MESSAGE_HEADER,
FIELDS_BROWSER_SELECTED_CATEGORY_TITLE,
FIELDS_BROWSER_SELECTED_CATEGORY_COUNT,
FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT,
FIELDS_BROWSER_FILTER_INPUT,
FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER,
FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES,
FIELDS_BROWSER_CATEGORY_BADGE,
} from '../../screens/fields_browser';
import { TIMELINE_FIELDS_BUTTON } from '../../screens/timeline';
import { cleanKibana } from '../../tasks/common';
@ -26,13 +25,14 @@ import {
clearFieldsBrowser,
closeFieldsBrowser,
filterFieldsBrowser,
toggleCategoryFilter,
removesMessageField,
resetFields,
toggleCategory,
} from '../../tasks/fields_browser';
import { loginAndWaitForPage } from '../../tasks/login';
import { openTimelineUsingToggle } from '../../tasks/security_main';
import { openTimelineFieldsBrowser, populateTimeline } from '../../tasks/timeline';
import { ecsFieldMap } from '../../../../rule_registry/common/assets/field_maps/ecs_field_map';
import { HOSTS_URL } from '../../urls/navigation';
@ -61,21 +61,8 @@ describe('Fields Browser', () => {
clearFieldsBrowser();
});
it('displays the `default ECS` category (by default)', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', 'default ECS');
});
it('the `defaultECS` (selected) category count matches the default timeline header count', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should(
'have.text',
`${defaultHeaders.length}`
);
});
it('displays a checked checkbox for all of the default timeline columns', () => {
defaultHeaders.forEach((header) =>
cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked')
);
it('displays all categories (by default)', () => {
cy.get(FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES).should('be.empty');
});
it('displays the expected count of categories that match the filter input', () => {
@ -83,54 +70,50 @@ describe('Fields Browser', () => {
filterFieldsBrowser(filterInput);
cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2 categories');
cy.get(FIELDS_BROWSER_CATEGORIES_COUNT).should('have.text', '2');
});
it('displays a search results label with the expected count of fields matching the filter input', () => {
const filterInput = 'host.mac';
filterFieldsBrowser(filterInput);
cy.get(FIELDS_BROWSER_HOST_CATEGORIES_COUNT)
.invoke('text')
.then((hostCategoriesCount) => {
cy.get(FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT)
.invoke('text')
.then((systemCategoriesCount) => {
cy.get(FIELDS_BROWSER_FIELDS_COUNT).should(
'have.text',
`${+hostCategoriesCount + +systemCategoriesCount} fields`
);
});
});
cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', '2');
});
it('displays a count of only the fields in the selected category that match the filter input', () => {
const filterInput = 'host.geo.c';
it('the `default ECS` category matches the default timeline header fields', () => {
const category = 'default ECS';
toggleCategory(category);
cy.get(FIELDS_BROWSER_FIELDS_COUNT).should('contain.text', `${defaultHeaders.length}`);
filterFieldsBrowser(filterInput);
defaultHeaders.forEach((header) => {
cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked');
});
toggleCategory(category);
});
const fieldsThatMatchFilterInput = Object.keys(ecsFieldMap).filter((fieldName) => {
const dotDelimitedFieldParts = fieldName.split('.');
const fieldPartMatch = dotDelimitedFieldParts.filter((fieldPart) => {
const camelCasedStringsMatching = fieldPart
.split('_')
.some((part) => part.startsWith(filterInput));
if (fieldPart.startsWith(filterInput)) {
return true;
} else if (camelCasedStringsMatching) {
return true;
} else {
return false;
}
});
return fieldName.startsWith(filterInput) || fieldPartMatch.length > 0;
}).length;
it('creates the category badge when it is selected', () => {
const category = 'host';
cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT).should(
'have.text',
fieldsThatMatchFilterInput
);
cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('not.exist');
toggleCategory(category);
cy.get(FIELDS_BROWSER_CATEGORY_BADGE(category)).should('exist');
toggleCategory(category);
});
it('search a category should match the category in the category filter', () => {
const category = 'host';
filterFieldsBrowser(category);
toggleCategoryFilter();
cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('contain.text', category);
});
it('search a category should filter out non matching categories in the category filter', () => {
const category = 'host';
const categoryCheck = 'event';
filterFieldsBrowser(category);
toggleCategoryFilter();
cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER).should('not.contain.text', categoryCheck);
});
});
@ -157,18 +140,15 @@ describe('Fields Browser', () => {
cy.get(FIELDS_BROWSER_MESSAGE_HEADER).should('not.exist');
});
it('selects a search results label with the expected count of categories matching the filter input', () => {
const category = 'host';
filterFieldsBrowser(category);
cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE).should('have.text', category);
});
it('adds a field to the timeline when the user clicks the checkbox', () => {
const filterInput = 'host.geo.c';
filterFieldsBrowser(filterInput);
closeFieldsBrowser();
cy.get(FIELDS_BROWSER_HOST_GEO_CITY_NAME_HEADER).should('not.exist');
openTimelineFieldsBrowser();
filterFieldsBrowser(filterInput);
addsHostGeoCityNameToTimeline();
closeFieldsBrowser();

View file

@ -7,20 +7,16 @@
export const CLOSE_BTN = '[data-test-subj="close"]';
export const FIELDS_BROWSER_CATEGORIES_COUNT = '[data-test-subj="categories-count"]';
export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]';
export const FIELDS_BROWSER_CHECKBOX = (id: string) => {
return `[data-test-subj="category-table-container"] [data-test-subj="field-${id}-checkbox"]`;
return `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-${id}-checkbox"]`;
};
export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]';
export const FIELDS_BROWSER_FIELDS_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="fields-count"]`;
export const FIELDS_BROWSER_FILTER_INPUT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="field-search"]`;
export const FIELDS_BROWSER_HOST_CATEGORIES_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="host-category-count"]`;
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 =
@ -38,8 +34,22 @@ export const FIELDS_BROWSER_MESSAGE_HEADER =
export const FIELDS_BROWSER_RESET_FIELDS = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="reset-fields"]`;
export const FIELDS_BROWSER_SELECTED_CATEGORY_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="selected-category-count-badge"]`;
export const FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="categories-filter-button"]`;
export const FIELDS_BROWSER_SELECTED_CATEGORY_COUNT = `${FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON} span.euiNotificationBadge`;
export const FIELDS_BROWSER_CATEGORIES_COUNT = `${FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON} span.euiNotificationBadge`;
export const FIELDS_BROWSER_SELECTED_CATEGORY_TITLE = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="selected-category-title"]`;
export const FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="category-badges"]`;
export const FIELDS_BROWSER_CATEGORY_BADGE = (id: string) => {
return `${FIELDS_BROWSER_SELECTED_CATEGORIES_BADGES} [data-test-subj="category-badge-${id}"]`;
};
export const FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER =
'[data-test-subj="categories-selector-container"]';
export const FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH =
'[data-test-subj="categories-selector-search"]';
export const FIELDS_BROWSER_CATEGORY_FILTER_OPTION = (id: string) => {
const idAttr = id.replace(/\s/g, '');
return `${FIELDS_BROWSER_CATEGORIES_FILTER_CONTAINER} [data-test-subj="categories-selector-option-${idAttr}"]`;
};
export const FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT = `${FIELDS_BROWSER_CONTAINER} [data-test-subj="system-category-count"]`;

View file

@ -13,6 +13,9 @@ import {
FIELDS_BROWSER_RESET_FIELDS,
FIELDS_BROWSER_CHECKBOX,
CLOSE_BTN,
FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON,
FIELDS_BROWSER_CATEGORY_FILTER_OPTION,
FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH,
} from '../screens/fields_browser';
export const addsFields = (fields: string[]) => {
@ -34,10 +37,9 @@ export const addsHostGeoContinentNameToTimeline = () => {
};
export const clearFieldsBrowser = () => {
cy.clock();
cy.get(FIELDS_BROWSER_FILTER_INPUT).type('{selectall}{backspace}');
cy.wait(0);
cy.tick(1000);
cy.get(FIELDS_BROWSER_FILTER_INPUT)
.type('{selectall}{backspace}')
.waitUntil((subject) => !subject.hasClass('euiFieldSearch-isLoading'));
};
export const closeFieldsBrowser = () => {
@ -46,12 +48,21 @@ export const closeFieldsBrowser = () => {
};
export const filterFieldsBrowser = (fieldName: string) => {
cy.clock();
cy.get(FIELDS_BROWSER_FILTER_INPUT).type(fieldName, { delay: 50 });
cy.wait(0);
cy.tick(1000);
// the text filter is debounced by 250 ms, wait 1s for changes to be applied
cy.get(FIELDS_BROWSER_FILTER_INPUT).should('not.have.class', 'euiFieldSearch-isLoading');
cy.get(FIELDS_BROWSER_FILTER_INPUT)
.clear()
.type(fieldName)
.waitUntil((subject) => !subject.hasClass('euiFieldSearch-isLoading'));
};
export const toggleCategoryFilter = () => {
cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_BUTTON).click({ force: true });
};
export const toggleCategory = (category: string) => {
toggleCategoryFilter();
cy.get(FIELDS_BROWSER_CATEGORIES_FILTER_SEARCH).clear().type(category);
cy.get(FIELDS_BROWSER_CATEGORY_FILTER_OPTION(category)).click({ force: true });
toggleCategoryFilter();
};
export const removesMessageField = () => {

View file

@ -34,7 +34,7 @@ jest.mock('../../../timelines/containers', () => ({
jest.mock('../../components/url_state/normalize_time_range.ts');
const mockUseCreateFieldButton = jest.fn().mockReturnValue(<></>);
jest.mock('../../../timelines/components/create_field_button', () => ({
jest.mock('../../../timelines/components/fields_browser/create_field_button', () => ({
useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params),
}));

View file

@ -30,9 +30,9 @@ import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants';
import { useGetUserCasesPermissions, useKibana } from '../../lib/kibana';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import {
useFieldBrowserOptions,
CreateFieldEditorActions,
useCreateFieldButton,
} from '../../../timelines/components/create_field_button';
} from '../../../timelines/components/fields_browser';
const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = [];
@ -177,7 +177,11 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
}, [id, timelineQuery, globalQuery]);
const bulkActions = useMemo(() => ({ onAlertStatusActionSuccess }), [onAlertStatusActionSuccess]);
const createFieldComponent = useCreateFieldButton(scopeId, id, editorActionsRef);
const fieldBrowserOptions = useFieldBrowserOptions({
sourcererScope: scopeId,
timelineId: id,
editorActionsRef,
});
const casesPermissions = useGetUserCasesPermissions();
const CasesContext = casesUi.getCasesContext();
@ -201,6 +205,7 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
docValueFields,
end,
entityType,
fieldBrowserOptions,
filters: globalFilters,
filterStatus: currentFilter,
globalFullScreen,
@ -228,7 +233,6 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
trailingControlColumns,
type: 'embedded',
unit,
createFieldComponent,
})}
</InspectButtonContainer>
</FullScreenContainer>

View file

@ -16,7 +16,7 @@ import { EuiToolTip } from '@elastic/eui';
* Note: Requires a parent container with a defined width or max-width.
*/
const EllipsisText = styled.span`
export const EllipsisText = styled.span`
&,
& * {
display: inline-block;

View file

@ -99,7 +99,6 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
const {
browserFields,
indexPattern: indexPatterns,
loading: indexPatternsLoading,
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.detections);
const kibana = useKibana();
@ -360,7 +359,7 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
const casesPermissions = useGetUserCasesPermissions();
const CasesContext = kibana.services.cases.getCasesContext();
if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) {
if (loading || isEmpty(selectedPatterns)) {
return null;
}

View file

@ -140,7 +140,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false);
const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false);
const loading = userInfoLoading || listsConfigLoading || isLoadingIndexPattern;
const loading = userInfoLoading || listsConfigLoading;
const {
application: { navigateToUrl },
timelines: timelinesUi,
@ -341,24 +341,32 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiSpacer size="m" />
<EuiFlexGroup wrap>
<EuiFlexItem grow={1}>
<AlertsCountPanel
filters={alertsHistogramDefaultFilters}
query={query}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
/>
{isLoadingIndexPattern ? (
<EuiLoadingSpinner size="xl" />
) : (
<AlertsCountPanel
filters={alertsHistogramDefaultFilters}
query={query}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={2}>
<AlertsHistogramPanel
chartHeight={CHART_HEIGHT}
filters={alertsHistogramDefaultFilters}
query={query}
showTotalAlertsCount={false}
titleSize={'s'}
signalIndexName={signalIndexName}
updateDateRange={updateDateRangeCallback}
runtimeMappings={runtimeMappings}
/>
{isLoadingIndexPattern ? (
<EuiLoadingSpinner size="xl" />
) : (
<AlertsHistogramPanel
chartHeight={CHART_HEIGHT}
filters={alertsHistogramDefaultFilters}
query={query}
showTotalAlertsCount={false}
titleSize={'s'}
signalIndexName={signalIndexName}
updateDateRange={updateDateRangeCallback}
runtimeMappings={runtimeMappings}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -11,15 +11,15 @@ import { CreateFieldButton, CreateFieldEditorActions } from './index';
import {
indexPatternFieldEditorPluginMock,
Start,
} from '../../../../../../../src/plugins/data_view_field_editor/public/mocks';
} from '../../../../../../../../src/plugins/data_view_field_editor/public/mocks';
import { TestProviders } from '../../../common/mock';
import { useKibana } from '../../../common/lib/kibana';
import type { DataView } from '../../../../../../../src/plugins/data/common';
import { TimelineId } from '../../../../common/types';
import { TestProviders } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana';
import type { DataView } from '../../../../../../../../src/plugins/data/common';
import { TimelineId } from '../../../../../common/types';
let mockIndexPatternFieldEditor: Start;
jest.mock('../../../common/lib/kibana');
jest.mock('../../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const runAllPromises = () => new Promise(setImmediate);

View file

@ -10,23 +10,26 @@ import { EuiButton } from '@elastic/eui';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import type { DataViewField, DataView } from '../../../../../../../src/plugins/data_views/common';
import { useKibana } from '../../../common/lib/kibana';
import type {
DataViewField,
DataView,
} from '../../../../../../../../src/plugins/data_views/common';
import { useKibana } from '../../../../common/lib/kibana';
import * as i18n from './translations';
import { CreateFieldComponentType, TimelineId } from '../../../../../timelines/common';
import { upsertColumn } from '../../../../../timelines/public';
import { useDataView } from '../../../common/containers/source/use_data_view';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { sourcererSelectors } from '../../../common/store';
import { useDeepEqualSelector } from '../../../common/hooks/use_selector';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/constants';
import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers';
import { FieldBrowserOptions, TimelineId } from '../../../../../../timelines/common';
import { upsertColumn } from '../../../../../../timelines/public';
import { useDataView } from '../../../../common/containers/source/use_data_view';
import { SourcererScopeName } from '../../../../common/store/sourcerer/model';
import { sourcererSelectors } from '../../../../common/store';
import { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../timeline/body/constants';
import { defaultColumnHeaderType } from '../../timeline/body/column_headers/default_headers';
export type CreateFieldEditorActions = { closeEditor: () => void } | null;
type CreateFieldEditorActionsRef = MutableRefObject<CreateFieldEditorActions>;
export type CreateFieldEditorActionsRef = MutableRefObject<CreateFieldEditorActions>;
interface CreateFieldButtonProps {
export interface CreateFieldButtonProps {
selectedDataViewId: string;
onClick: () => void;
timelineId: TimelineId;
@ -142,7 +145,7 @@ export const useCreateFieldButton = (
return;
}
// It receives onClick props from field browser in order to close the modal.
const CreateFieldButtonComponent: CreateFieldComponentType = ({ onClick }) => (
const CreateFieldButtonComponent: FieldBrowserOptions['createFieldButton'] = ({ onClick }) => (
<CreateFieldButton
selectedDataViewId={selectedDataViewId}
onClick={onClick}

View file

@ -1,143 +0,0 @@
/*
* 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 { isEmpty } from 'lodash/fp';
import React, { useCallback, useRef, useState } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import {
DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME,
getDraggableFieldId,
} from '@kbn/securitysolution-t-grid';
import type { BrowserFields } from '../../../common/containers/source';
import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers';
import type { OnUpdateColumns } from '../timeline/events';
import { FieldName } from './field_name';
import type { ColumnHeaderOptions } from '../../../../common/types';
import { useKibana } from '../../../common/lib/kibana';
const DraggableFieldsBrowserFieldComponent = ({
browserFields,
categoryId,
fieldCategory,
fieldName,
highlight = '',
onUpdateColumns,
timelineId,
toggleColumn,
}: {
browserFields: BrowserFields;
categoryId: string;
fieldCategory: string;
fieldName: string;
highlight?: string;
onUpdateColumns: OnUpdateColumns;
timelineId: string;
toggleColumn: (column: ColumnHeaderOptions) => void;
}) => {
const keyboardHandlerRef = useRef<HTMLDivElement | null>(null);
const [closePopOverTrigger, setClosePopOverTrigger] = useState<boolean>(false);
const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState<boolean>(false);
const { timelines } = useKibana().services;
const handleClosePopOverTrigger = useCallback(() => {
setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger);
setHoverActionsOwnFocus((prevHoverActionsOwnFocus) => {
if (prevHoverActionsOwnFocus) {
// on the next tick, re-focus the keyboard handler if the hover actions owned focus
setTimeout(() => {
keyboardHandlerRef.current?.focus();
}, 0);
}
return false; // always give up ownership
});
setTimeout(() => {
setHoverActionsOwnFocus(false);
}, 0); // invoked on the next tick, because we want to restore focus first
}, []);
const openPopover = useCallback(() => {
setHoverActionsOwnFocus(true);
}, [setHoverActionsOwnFocus]);
const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({
closePopover: handleClosePopOverTrigger,
draggableId: getDraggableFieldId({
contextId: `field-browser-field-items-field-draggable-${timelineId}-${categoryId}-${fieldName}`,
fieldId: fieldName,
}),
fieldName,
keyboardHandlerRef,
openPopover,
});
const onFocus = useCallback(() => {
keyboardHandlerRef.current?.focus();
}, []);
const onCloseRequested = useCallback(() => {
setHoverActionsOwnFocus((prevHoverActionOwnFocus) =>
prevHoverActionOwnFocus ? false : prevHoverActionOwnFocus
);
setTimeout(() => {
onFocus(); // return focus to this draggable on the next tick, because we owned focus
}, 0);
}, [onFocus]);
return (
<div
className={DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}
data-test-subj="draggableWrapperKeyboardHandler"
data-colindex={2}
onClick={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
ref={keyboardHandlerRef}
role="button"
tabIndex={0}
>
<Draggable
draggableId={getDraggableFieldId({
contextId: `field-browser-field-items-field-draggable-${timelineId}-${categoryId}-${fieldName}`,
fieldId: fieldName,
})}
index={0}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
tabIndex={-1}
>
<FieldName
categoryId={isEmpty(fieldCategory) ? categoryId : fieldCategory}
categoryColumns={getColumnsWithTimestamp({
browserFields,
category: isEmpty(fieldCategory) ? categoryId : fieldCategory,
})}
closePopOverTrigger={closePopOverTrigger}
data-test-subj="field-name"
fieldId={fieldName}
handleClosePopOverTrigger={handleClosePopOverTrigger}
highlight={highlight}
hoverActionsOwnFocus={hoverActionsOwnFocus}
onCloseRequested={onCloseRequested}
onUpdateColumns={onUpdateColumns}
/>
</div>
)}
</Draggable>
</div>
);
};
export const DraggableFieldsBrowserField = React.memo(DraggableFieldsBrowserFieldComponent);
DraggableFieldsBrowserField.displayName = 'DraggableFieldsBrowserFieldComponent';

View file

@ -1,81 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { waitFor } from '@testing-library/react';
import { mockBrowserFields } from '../../../common/containers/source/mock';
import { TestProviders } from '../../../common/mock';
import '../../../common/mock/match_media';
import { getColumnsWithTimestamp } from '../../../common/components/event_details/helpers';
import { FieldName } from './field_name';
jest.mock('../../../common/lib/kibana');
const categoryId = 'base';
const timestampFieldId = '@timestamp';
const defaultProps = {
categoryId,
categoryColumns: getColumnsWithTimestamp({
browserFields: mockBrowserFields,
category: categoryId,
}),
closePopOverTrigger: false,
fieldId: timestampFieldId,
handleClosePopOverTrigger: jest.fn(),
hoverActionsOwnFocus: false,
onCloseRequested: jest.fn(),
onUpdateColumns: jest.fn(),
setClosePopOverTrigger: jest.fn(),
};
describe('FieldName', () => {
beforeEach(() => {
jest.useFakeTimers();
});
test('it renders the field name', () => {
const wrapper = mount(
<TestProviders>
<FieldName {...defaultProps} />
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="field-name-${timestampFieldId}"]`).first().text()
).toEqual(timestampFieldId);
});
test('it renders a copy to clipboard action menu item a user hovers over the name', async () => {
const wrapper = mount(
<TestProviders>
<FieldName {...defaultProps} />
</TestProviders>
);
await waitFor(() => {
wrapper.find('[data-test-subj="withHoverActionsButton"]').simulate('mouseenter');
wrapper.update();
jest.runAllTimers();
wrapper.update();
expect(wrapper.find('[data-test-subj="hover-actions-copy-button"]').exists()).toBe(true);
});
});
test('it highlights the text specified by the `highlight` prop', () => {
const highlight = 'stamp';
const wrapper = mount(
<TestProviders>
<FieldName {...{ ...defaultProps, highlight }} />
</TestProviders>
);
expect(wrapper.find('mark').first().text()).toEqual(highlight);
});
});

View file

@ -1,165 +0,0 @@
/*
* 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 { EuiHighlight, EuiText } from '@elastic/eui';
import React, { useCallback, useState, useMemo, useRef, useContext } from 'react';
import styled from 'styled-components';
import { OnUpdateColumns } from '../timeline/events';
import { WithHoverActions } from '../../../common/components/with_hover_actions';
import { ColumnHeaderOptions } from '../../../../common/types';
import { HoverActions } from '../../../common/components/hover_actions';
import { TimelineContext } from '../../../../../timelines/public';
/**
* The name of a (draggable) field
*/
export const FieldNameContainer = styled.span`
border-radius: 4px;
display: flex;
padding: 0 4px 0 8px;
position: relative;
&::before {
background-image: linear-gradient(
135deg,
${({ theme }) => theme.eui.euiColorMediumShade} 25%,
transparent 25%
),
linear-gradient(-135deg, ${({ theme }) => theme.eui.euiColorMediumShade} 25%, transparent 25%),
linear-gradient(135deg, transparent 75%, ${({ theme }) => theme.eui.euiColorMediumShade} 75%),
linear-gradient(-135deg, transparent 75%, ${({ theme }) => theme.eui.euiColorMediumShade} 75%);
background-position: 0 0, 1px 0, 1px -1px, 0px 1px;
background-size: 2px 2px;
bottom: 2px;
content: '';
display: block;
left: 2px;
position: absolute;
top: 2px;
width: 4px;
}
&:hover,
&:focus {
transition: background-color 0.7s ease;
background-color: #000;
color: #fff;
&::before {
background-image: linear-gradient(135deg, #fff 25%, transparent 25%),
linear-gradient(
-135deg,
${({ theme }) => theme.eui.euiColorLightestShade} 25%,
transparent 25%
),
linear-gradient(
135deg,
transparent 75%,
${({ theme }) => theme.eui.euiColorLightestShade} 75%
),
linear-gradient(
-135deg,
transparent 75%,
${({ theme }) => theme.eui.euiColorLightestShade} 75%
);
}
}
`;
FieldNameContainer.displayName = 'FieldNameContainer';
/** Renders a field name in it's non-dragging state */
export const FieldName = React.memo<{
categoryId: string;
categoryColumns: ColumnHeaderOptions[];
closePopOverTrigger: boolean;
fieldId: string;
highlight?: string;
handleClosePopOverTrigger: () => void;
hoverActionsOwnFocus: boolean;
onCloseRequested: () => void;
onUpdateColumns: OnUpdateColumns;
}>(
({
closePopOverTrigger,
fieldId,
highlight = '',
handleClosePopOverTrigger,
hoverActionsOwnFocus,
onCloseRequested,
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [showTopN, setShowTopN] = useState<boolean>(false);
const { timelineId: timelineIdFind } = useContext(TimelineContext);
const toggleTopN = useCallback(() => {
setShowTopN((prevShowTopN) => {
const newShowTopN = !prevShowTopN;
if (newShowTopN === false) {
handleClosePopOverTrigger();
}
return newShowTopN;
});
}, [handleClosePopOverTrigger]);
const closeTopN = useCallback(() => {
setShowTopN(false);
}, []);
const hoverContent = useMemo(
() => (
<HoverActions
closeTopN={closeTopN}
closePopOver={handleClosePopOverTrigger}
field={fieldId}
isObjectArray={false}
ownFocus={hoverActionsOwnFocus}
showTopN={showTopN}
toggleTopN={toggleTopN}
timelineId={timelineIdFind}
/>
),
[
closeTopN,
fieldId,
handleClosePopOverTrigger,
hoverActionsOwnFocus,
showTopN,
timelineIdFind,
toggleTopN,
]
);
const render = useCallback(
() => (
<EuiText size="xs">
<FieldNameContainer>
<EuiHighlight data-test-subj={`field-name-${fieldId}`} search={highlight}>
{fieldId}
</EuiHighlight>
</FieldNameContainer>
</EuiText>
),
[fieldId, highlight]
);
return (
<div ref={containerRef}>
<WithHoverActions
alwaysShow={showTopN || hoverActionsOwnFocus}
closePopOverTrigger={closePopOverTrigger}
hoverContent={hoverContent}
onCloseRequested={onCloseRequested}
render={render}
/>
</div>
);
}
);
FieldName.displayName = 'FieldName';

View file

@ -0,0 +1,117 @@
/*
* 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 styled from 'styled-components';
import {
EuiToolTip,
EuiFlexGroup,
EuiFlexItem,
EuiScreenReaderOnly,
EuiHealth,
EuiBadge,
EuiIcon,
EuiText,
EuiHighlight,
} from '@elastic/eui';
import type { FieldTableColumns } from '../../../../../../timelines/common/types';
import * as i18n from './translations';
import {
getExampleText,
getIconFromType,
} from '../../../../common/components/event_details/helpers';
import { getEmptyValue } from '../../../../common/components/empty_value';
import { EllipsisText } from '../../../../common/components/truncatable_text';
const TypeIcon = styled(EuiIcon)`
margin: 0 4px;
position: relative;
top: -1px;
`;
TypeIcon.displayName = 'TypeIcon';
export const Description = styled.span`
user-select: text;
width: 400px;
`;
Description.displayName = 'Description';
export const FieldName = React.memo<{
fieldId: string;
highlight?: string;
}>(({ fieldId, highlight = '' }) => (
<EuiText size="xs">
<EuiHighlight data-test-subj={`field-${fieldId}-name`} search={highlight}>
{fieldId}
</EuiHighlight>
</EuiText>
));
FieldName.displayName = 'FieldName';
export const getFieldTableColumns = (highlight: string): FieldTableColumns => [
{
field: 'name',
name: i18n.NAME,
render: (name: string, { type }) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={type}>
<TypeIcon
data-test-subj={`field-${name}-icon`}
type={getIconFromType(type ?? null)}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldName fieldId={name} highlight={highlight} />
</EuiFlexItem>
</EuiFlexGroup>
);
},
sortable: true,
width: '200px',
},
{
field: 'description',
name: i18n.DESCRIPTION,
render: (description, { name, example }) => (
<EuiToolTip content={description}>
<>
<EuiScreenReaderOnly data-test-subj="descriptionForScreenReaderOnly">
<p>{i18n.DESCRIPTION_FOR_FIELD(name)}</p>
</EuiScreenReaderOnly>
<EllipsisText>
<Description data-test-subj={`field-${name}-description`}>
{`${description ?? getEmptyValue()} ${getExampleText(example)}`}
</Description>
</EllipsisText>
</>
</EuiToolTip>
),
sortable: true,
width: '400px',
},
{
field: 'isRuntime',
name: i18n.RUNTIME,
render: (isRuntime: boolean) =>
isRuntime ? <EuiHealth color="success" title={i18n.RUNTIME_FIELD} /> : null,
sortable: true,
width: '80px',
},
{
field: 'category',
name: i18n.CATEGORY,
render: (category: string, { name }) => (
<EuiBadge data-test-subj={`field-${name}-category`}>{category}</EuiBadge>
),
sortable: true,
width: '100px',
},
];

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const NAME = i18n.translate('xpack.securitySolution.fieldBrowser.fieldName', {
defaultMessage: 'Name',
});
export const DESCRIPTION = i18n.translate('xpack.securitySolution.fieldBrowser.descriptionLabel', {
defaultMessage: 'Description',
});
export const DESCRIPTION_FOR_FIELD = (field: string) =>
i18n.translate('xpack.securitySolution.fieldBrowser.descriptionForScreenReaderOnly', {
values: {
field,
},
defaultMessage: 'Description for field {field}:',
});
export const CATEGORY = i18n.translate('xpack.securitySolution.fieldBrowser.categoryLabel', {
defaultMessage: 'Category',
});
export const RUNTIME = i18n.translate('xpack.securitySolution.fieldBrowser.runtimeLabel', {
defaultMessage: 'Runtime',
});
export const RUNTIME_FIELD = i18n.translate('xpack.securitySolution.fieldBrowser.runtimeTitle', {
defaultMessage: 'Runtime Field',
});

View file

@ -0,0 +1,31 @@
/*
* 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 { TimelineId } from '../../../../common/types';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useCreateFieldButton, CreateFieldEditorActionsRef } from './create_field_button';
import { getFieldTableColumns } from './field_table_columns';
export type { CreateFieldEditorActions } from './create_field_button';
export interface UseFieldBrowserOptions {
sourcererScope: SourcererScopeName;
timelineId: TimelineId;
editorActionsRef?: CreateFieldEditorActionsRef;
}
export const useFieldBrowserOptions = ({
sourcererScope,
timelineId,
editorActionsRef,
}: UseFieldBrowserOptions) => {
const createFieldButton = useCreateFieldButton(sourcererScope, timelineId, editorActionsRef);
return {
createFieldButton,
getFieldTableColumns,
};
};

View file

@ -86,7 +86,7 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
sort,
tabType,
timelineId,
createFieldComponent,
fieldBrowserOptions,
}) => {
const { timelines: timelinesUi } = useKibana().services;
const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen();
@ -184,7 +184,7 @@ const HeaderActionsComponent: React.FC<HeaderActionProps> = ({
browserFields,
columnHeaders,
timelineId,
createFieldComponent,
options: fieldBrowserOptions,
})}
</FieldBrowserContainer>
</EventsTh>

View file

@ -28,7 +28,7 @@ import { HeaderActions } from '../actions/header_actions';
jest.mock('../../../../../common/lib/kibana');
const mockUseCreateFieldButton = jest.fn().mockReturnValue(<></>);
jest.mock('../../../create_field_button', () => ({
jest.mock('../../../fields_browser/create_field_button', () => ({
useCreateFieldButton: (...params: unknown[]) => mockUseCreateFieldButton(...params),
}));

View file

@ -34,7 +34,7 @@ import { Sort } from '../sort';
import { ColumnHeader } from './column_header';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
import { CreateFieldEditorActions, useCreateFieldButton } from '../../../create_field_button';
import { useFieldBrowserOptions, CreateFieldEditorActions } from '../../../fields_browser';
export interface ColumnHeadersComponentProps {
actionsColumnWidth: number;
@ -190,11 +190,11 @@ export const ColumnHeadersComponent = ({
[trailingControlColumns]
);
const createFieldComponent = useCreateFieldButton(
SourcererScopeName.timeline,
timelineId as TimelineId,
fieldEditorActionsRef
);
const fieldBrowserOptions = useFieldBrowserOptions({
sourcererScope: SourcererScopeName.timeline,
timelineId: timelineId as TimelineId,
editorActionsRef: fieldEditorActionsRef,
});
const LeadingHeaderActions = useMemo(() => {
return leadingHeaderCells.map(
@ -221,7 +221,7 @@ export const ColumnHeadersComponent = ({
sort={sort}
tabType={tabType}
timelineId={timelineId}
createFieldComponent={createFieldComponent}
fieldBrowserOptions={fieldBrowserOptions}
/>
)}
</EventsThGroupActions>
@ -234,7 +234,7 @@ export const ColumnHeadersComponent = ({
actionsColumnWidth,
browserFields,
columnHeaders,
createFieldComponent,
fieldBrowserOptions,
isEventViewer,
isSelectAllChecked,
onSelectAll,
@ -270,7 +270,7 @@ export const ColumnHeadersComponent = ({
sort={sort}
tabType={tabType}
timelineId={timelineId}
createFieldComponent={createFieldComponent}
fieldBrowserOptions={fieldBrowserOptions}
/>
)}
</EventsThGroupActions>
@ -283,7 +283,7 @@ export const ColumnHeadersComponent = ({
actionsColumnWidth,
browserFields,
columnHeaders,
createFieldComponent,
fieldBrowserOptions,
isEventViewer,
isSelectAllChecked,
onSelectAll,

View file

@ -114,7 +114,7 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({
maxDelay: () => 3000,
}));
jest.mock('../../create_field_button', () => ({
jest.mock('../../fields_browser/create_field_button', () => ({
useCreateFieldButton: () => <></>,
}));

View file

@ -20,7 +20,6 @@ export type {
ActionProps,
AlertWorkflowStatus,
CellValueElementProps,
CreateFieldComponentType,
ColumnId,
ColumnRenderer,
ColumnHeaderType,
@ -28,6 +27,7 @@ export type {
ControlColumnProps,
DataProvidersAnd,
DataProvider,
FieldBrowserOptions,
GenericActionRowCellRenderProps,
HeaderActionProps,
HeaderCellRender,

View file

@ -11,6 +11,7 @@ import type {
IEsSearchRequest,
IEsSearchResponse,
FieldSpec,
RuntimeField,
} from '../../../../../../src/plugins/data/common';
import type { DocValueFields, Maybe } from '../common';
@ -71,6 +72,7 @@ export interface BrowserField {
type: string;
subType?: IFieldSubType;
readFromDocValues: boolean;
runtimeField?: RuntimeField;
}
export type BrowserFields = Readonly<Record<string, Partial<BrowserField>>>;

View file

@ -0,0 +1,50 @@
/*
* 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 { EuiBasicTableColumn } from '@elastic/eui';
import { BrowserFields } from '../../search_strategy';
import { ColumnHeaderOptions } from '../timeline/columns';
/**
* An item rendered in the table
*/
export interface BrowserFieldItem {
name: string;
type?: string;
description?: string;
example?: string;
category: string;
selected: boolean;
isRuntime: boolean;
}
export type OnFieldSelected = (fieldId: string) => void;
export type CreateFieldComponent = React.FC<{
onClick: () => void;
}>;
export type FieldTableColumns = Array<EuiBasicTableColumn<BrowserFieldItem>>;
export type GetFieldTableColumns = (highlight: string) => FieldTableColumns;
export interface FieldBrowserOptions {
createFieldButton?: CreateFieldComponent;
getFieldTableColumns?: GetFieldTableColumns;
}
export interface FieldBrowserProps {
/** The timeline associated with this field browser */
timelineId: string;
/** The timeline's current column headers */
columnHeaders: ColumnHeaderOptions[];
/** A map of categoryId -> metadata about the fields in that category */
browserFields: BrowserFields;
/** When true, this Fields Browser is being used as an "events viewer" */
isEventViewer?: boolean;
/** The options to customize the field browser, supporting columns rendering and button to create fields */
options?: FieldBrowserOptions;
/** The width of the field browser */
width?: number;
}

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export * from './fields_browser';
export * from './timeline';

View file

@ -7,11 +7,12 @@
import { ComponentType, JSXElementConstructor } from 'react';
import { EuiDataGridControlColumn, EuiDataGridCellValueElementProps } from '@elastic/eui';
import { CreateFieldComponentType, OnRowSelected, SortColumnTimeline, TimelineTabs } from '..';
import { OnRowSelected, SortColumnTimeline, TimelineTabs } from '..';
import { BrowserFields } from '../../../search_strategy/index_fields';
import { ColumnHeaderOptions } from '../columns';
import { TimelineNonEcsData } from '../../../search_strategy';
import { Ecs } from '../../../ecs';
import { FieldBrowserOptions } from '../../fields_browser';
export interface ActionProps {
action?: RowCellRender;
@ -67,7 +68,7 @@ export interface HeaderActionProps {
width: number;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
createFieldComponent?: CreateFieldComponentType;
fieldBrowserOptions?: FieldBrowserOptions;
isEventViewer?: boolean;
isSelectAllChecked: boolean;
onSelectAll: ({ isSelected }: { isSelected: boolean }) => void;

View file

@ -465,10 +465,6 @@ export enum TimelineTabs {
eql = 'eql',
}
export type CreateFieldComponentType = React.FC<{
onClick: () => void;
}>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EmptyObject = Partial<Record<any, never>>;

View file

@ -9,9 +9,14 @@ import React from 'react';
import type { Store } from 'redux';
import { Provider } from 'react-redux';
import { I18nProvider } from '@kbn/i18n-react';
import type { FieldBrowserProps } from '../t_grid/toolbar/fields_browser/types';
import { StatefulFieldsBrowser } from '../t_grid/toolbar/fields_browser';
export type { FieldBrowserProps } from '../t_grid/toolbar/fields_browser/types';
import { FieldBrowserProps } from '../../../common/types/fields_browser';
export type {
CreateFieldComponent,
FieldBrowserOptions,
FieldBrowserProps,
GetFieldTableColumns,
} from '../../../common/types/fields_browser';
const EMPTY_BROWSER_FIELDS = {};

View file

@ -47,7 +47,6 @@ import {
TimelineTabs,
SetEventsLoading,
SetEventsDeleted,
CreateFieldComponentType,
} from '../../../../common/types/timeline';
import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy/timeline';
@ -63,10 +62,11 @@ import {
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import type { OnRowSelected, OnSelectAll } from '../types';
import type { FieldBrowserOptions } from '../../../../common/types';
import type { Refetch } from '../../../store/t_grid/inputs';
import { getPageRowIndex } from '../../../../common/utils/pagination';
import { StatefulEventContext } from '../../../components/stateful_event_context';
import { StatefulFieldsBrowser } from '../../../components/t_grid/toolbar/fields_browser';
import { StatefulFieldsBrowser } from '../toolbar/fields_browser';
import { tGridActions, TGridModel, tGridSelectors, TimelineState } from '../../../store/t_grid';
import { useDeepEqualSelector } from '../../../hooks/use_selector';
import { RowAction } from './row_action';
@ -88,10 +88,10 @@ interface OwnProps {
appId?: string;
browserFields: BrowserFields;
bulkActions?: BulkActionsProp;
createFieldComponent?: CreateFieldComponentType;
data: TimelineItem[];
defaultCellActions?: TGridCellAction[];
disabledCellActions: string[];
fieldBrowserOptions?: FieldBrowserOptions;
filters?: Filter[];
filterQuery?: string;
filterStatus?: AlertStatus;
@ -149,8 +149,8 @@ const EuiDataGridContainer = styled.div<{ hideLastPage: boolean }>`
const transformControlColumns = ({
columnHeaders,
controlColumns,
createFieldComponent,
data,
fieldBrowserOptions,
isEventViewer = false,
loadingEventIds,
onRowSelected,
@ -171,9 +171,9 @@ const transformControlColumns = ({
}: {
columnHeaders: ColumnHeaderOptions[];
controlColumns: ControlColumnProps[];
createFieldComponent?: CreateFieldComponentType;
data: TimelineItem[];
disabledCellActions: string[];
fieldBrowserOptions?: FieldBrowserOptions;
isEventViewer?: boolean;
loadingEventIds: string[];
onRowSelected: OnRowSelected;
@ -209,6 +209,7 @@ const transformControlColumns = ({
<HeaderActions
width={width}
browserFields={browserFields}
fieldBrowserOptions={fieldBrowserOptions}
columnHeaders={columnHeaders}
isEventViewer={isEventViewer}
isSelectAllChecked={isSelectAllChecked}
@ -218,7 +219,6 @@ const transformControlColumns = ({
sort={sort}
tabType={tabType}
timelineId={timelineId}
createFieldComponent={createFieldComponent}
/>
)}
</>
@ -303,10 +303,10 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
bulkActions = true,
clearSelected,
columnHeaders,
createFieldComponent,
data,
defaultCellActions,
disabledCellActions,
fieldBrowserOptions,
filterQuery,
filters,
filterStatus,
@ -502,7 +502,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
<StatefulFieldsBrowser
data-test-subj="field-browser"
browserFields={browserFields}
createFieldComponent={createFieldComponent}
options={fieldBrowserOptions}
timelineId={id}
columnHeaders={columnHeaders}
/>
@ -529,6 +529,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
id,
totalSelectAllAlerts,
totalItems,
fieldBrowserOptions,
filterStatus,
filterQuery,
indexNames,
@ -539,7 +540,6 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
additionalControls,
browserFields,
columnHeaders,
createFieldComponent,
]
);
@ -629,9 +629,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
transformControlColumns({
columnHeaders,
controlColumns,
createFieldComponent,
data,
disabledCellActions,
fieldBrowserOptions,
isEventViewer,
loadingEventIds,
onRowSelected,
@ -656,9 +656,9 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
leadingControlColumns,
trailingControlColumns,
columnHeaders,
createFieldComponent,
data,
disabledCellActions,
fieldBrowserOptions,
isEventViewer,
id,
loadingEventIds,

View file

@ -21,7 +21,6 @@ import type { CoreStart } from '../../../../../../../src/core/public';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import {
BulkActionsProp,
CreateFieldComponentType,
TGridCellAction,
TimelineId,
TimelineTabs,
@ -43,6 +42,7 @@ import { defaultHeaders } from '../body/column_headers/default_headers';
import { buildCombinedQuery, getCombinedFilterQuery, resolverIsShowing } from '../helpers';
import { tGridActions, tGridSelectors } from '../../../store/t_grid';
import { useTimelineEvents, InspectResponse, Refetch } from '../../../container';
import { FieldBrowserOptions } from '../../fields_browser';
import { StatefulBody } from '../body';
import { SELECTOR_TIMELINE_GLOBAL_CONTAINER, UpdatedFlexGroup, UpdatedFlexItem } from '../styles';
import { Sort } from '../body/sort';
@ -98,7 +98,6 @@ export interface TGridIntegratedProps {
browserFields: BrowserFields;
bulkActions?: BulkActionsProp;
columns: ColumnHeaderOptions[];
createFieldComponent?: CreateFieldComponentType;
data?: DataPublicPluginStart;
dataProviders: DataProvider[];
dataViewId?: string | null;
@ -108,6 +107,7 @@ export interface TGridIntegratedProps {
docValueFields: DocValueFields[];
end: string;
entityType: EntityType;
fieldBrowserOptions?: FieldBrowserOptions;
filters: Filter[];
filterStatus?: AlertStatus;
globalFullScreen: boolean;
@ -153,12 +153,12 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
docValueFields,
end,
entityType,
fieldBrowserOptions,
filters,
filterStatus,
globalFullScreen,
graphEventId,
graphOverlay = null,
createFieldComponent,
hasAlertsCrud,
id,
indexNames,
@ -363,10 +363,10 @@ const TGridIntegratedComponent: React.FC<TGridIntegratedProps> = ({
appId={appId}
browserFields={browserFields}
bulkActions={bulkActions}
createFieldComponent={createFieldComponent}
data={nonDeletedEvents}
defaultCellActions={defaultCellActions}
disabledCellActions={disabledCellActions}
fieldBrowserOptions={fieldBrowserOptions}
filterQuery={filterQuery}
filters={filters}
filterStatus={filterStatus}

View file

@ -0,0 +1,60 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../../../mock';
import { CategoriesBadges } from './categories_badges';
const mockSetSelectedCategoryIds = jest.fn();
const defaultProps = {
setSelectedCategoryIds: mockSetSelectedCategoryIds,
selectedCategoryIds: [],
};
describe('CategoriesBadges', () => {
beforeEach(() => {
mockSetSelectedCategoryIds.mockClear();
});
it('should render empty badges', () => {
const result = render(
<TestProviders>
<CategoriesBadges {...defaultProps} />
</TestProviders>
);
const badges = result.getByTestId('category-badges');
expect(badges).toBeInTheDocument();
expect(badges.childNodes.length).toBe(0);
});
it('should render the selector button with selected categories', () => {
const result = render(
<TestProviders>
<CategoriesBadges {...defaultProps} selectedCategoryIds={['base', 'event']} />
</TestProviders>
);
const badges = result.getByTestId('category-badges');
expect(badges.childNodes.length).toBe(2);
expect(result.getByTestId('category-badge-base')).toBeInTheDocument();
expect(result.getByTestId('category-badge-event')).toBeInTheDocument();
});
it('should call the set selected callback when badge unselect button clicked', () => {
const result = render(
<TestProviders>
<CategoriesBadges {...defaultProps} selectedCategoryIds={['base', 'event']} />
</TestProviders>
);
result.getByTestId('category-badge-unselect-base').click();
expect(mockSetSelectedCategoryIds).toHaveBeenCalledWith(['event']);
});
});

View file

@ -0,0 +1,56 @@
/*
* 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 } from 'react';
import styled from 'styled-components';
import { EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
interface CategoriesBadgesProps {
setSelectedCategoryIds: (categoryIds: string[]) => void;
selectedCategoryIds: string[];
}
const CategoriesBadgesGroup = styled(EuiFlexGroup)`
margin-top: ${({ theme }) => theme.eui.euiSizeXS};
min-height: 24px;
`;
CategoriesBadgesGroup.displayName = 'CategoriesBadgesGroup';
const CategoriesBadgesComponent: React.FC<CategoriesBadgesProps> = ({
setSelectedCategoryIds,
selectedCategoryIds,
}) => {
const onUnselectCategory = useCallback(
(categoryId: string) => {
setSelectedCategoryIds(
selectedCategoryIds.filter((selectedCategoryId) => selectedCategoryId !== categoryId)
);
},
[setSelectedCategoryIds, selectedCategoryIds]
);
return (
<CategoriesBadgesGroup data-test-subj="category-badges" gutterSize="xs" wrap>
{selectedCategoryIds.map((categoryId) => (
<EuiFlexItem grow={false} key={categoryId}>
<EuiBadge
iconType="cross"
iconSide="right"
iconOnClick={() => onUnselectCategory(categoryId)}
iconOnClickAriaLabel="unselect category"
data-test-subj={`category-badge-${categoryId}`}
closeButtonProps={{ 'data-test-subj': `category-badge-unselect-${categoryId}` }}
>
{categoryId}
</EuiBadge>
</EuiFlexItem>
))}
</CategoriesBadgesGroup>
);
};
export const CategoriesBadges = React.memo(CategoriesBadgesComponent);

View file

@ -1,51 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { mockBrowserFields } from '../../../../mock';
import { CATEGORY_PANE_WIDTH } from './helpers';
import { CategoriesPane } from './categories_pane';
import * as i18n from './translations';
const timelineId = 'test';
describe('CategoriesPane', () => {
test('it renders the expected title', () => {
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={''}
timelineId={timelineId}
/>
);
expect(wrapper.find('[data-test-subj="categories-pane-title"]').first().text()).toEqual(
i18n.CATEGORIES
);
});
test('it renders a "No fields match" message when filteredBrowserFields is empty', () => {
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={{}}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={''}
timelineId={timelineId}
/>
);
expect(wrapper.find('[data-test-subj="categories-container"] tbody').first().text()).toEqual(
i18n.NO_FIELDS_MATCH
);
});
});

View file

@ -1,118 +0,0 @@
/*
* 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 { EuiInMemoryTable, EuiTitle } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { useCallback, useRef } from 'react';
import styled from 'styled-components';
import {
DATA_COLINDEX_ATTRIBUTE,
DATA_ROWINDEX_ATTRIBUTE,
onKeyDownFocusHandler,
} from '../../../../../common/utils/accessibility';
import type { BrowserFields } from '../../../../../common/search_strategy';
import { getCategoryColumns } from './category_columns';
import { CATEGORIES_PANE_CLASS_NAME, TABLE_HEIGHT } from './helpers';
import * as i18n from './translations';
const CategoryNames = styled.div<{ height: number; width: number }>`
${({ width }) => `width: ${width}px`};
${({ height }) => `height: ${height}px`};
overflow-y: hidden;
padding: 5px;
thead {
display: none;
}
`;
CategoryNames.displayName = 'CategoryNames';
const Title = styled(EuiTitle)`
padding-left: 5px;
`;
const H3 = styled.h3`
text-align: left;
`;
Title.displayName = 'Title';
interface Props {
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/**
* Invoked when the user clicks on the name of a category in the left-hand
* side of the field browser
*/
onCategorySelected: (categoryId: string) => void;
/** The category selected on the left-hand side of the field browser */
selectedCategoryId: string;
timelineId: string;
/** The width of the categories pane */
width: number;
}
export const CategoriesPane = React.memo<Props>(
({ filteredBrowserFields, onCategorySelected, selectedCategoryId, timelineId, width }) => {
const containerElement = useRef<HTMLDivElement | null>(null);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
onKeyDownFocusHandler({
colindexAttribute: DATA_COLINDEX_ATTRIBUTE,
containerElement: containerElement?.current,
event: e,
maxAriaColindex: 1,
maxAriaRowindex: Object.keys(filteredBrowserFields).length,
onColumnFocused: noop,
rowindexAttribute: DATA_ROWINDEX_ATTRIBUTE,
});
},
[containerElement, filteredBrowserFields]
);
return (
<>
<Title size="xxs">
<H3 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</H3>
</Title>
<CategoryNames
className={`${CATEGORIES_PANE_CLASS_NAME} euiTable--compressed`}
data-test-subj="categories-container"
onKeyDown={onKeyDown}
ref={containerElement}
width={width}
height={TABLE_HEIGHT}
>
<EuiInMemoryTable
className="eui-yScroll"
columns={getCategoryColumns({
filteredBrowserFields,
onCategorySelected,
selectedCategoryId,
timelineId,
})}
items={Object.keys(filteredBrowserFields)
.sort()
.map((categoryId, i) => ({ categoryId, ariaRowindex: i + 1 }))}
message={i18n.NO_FIELDS_MATCH}
pagination={false}
sorting={false}
tableCaption={i18n.CATEGORIES}
/>
</CategoryNames>
</>
);
}
);
CategoriesPane.displayName = 'CategoriesPane';

View file

@ -0,0 +1,92 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { CategoriesSelector } from './categories_selector';
const mockSetSelectedCategoryIds = jest.fn();
const defaultProps = {
filteredBrowserFields: mockBrowserFields,
setSelectedCategoryIds: mockSetSelectedCategoryIds,
selectedCategoryIds: [],
};
describe('CategoriesSelector', () => {
beforeEach(() => {
mockSetSelectedCategoryIds.mockClear();
});
it('should render the default selector button', () => {
const categoriesCount = Object.keys(mockBrowserFields).length;
const result = render(
<TestProviders>
<CategoriesSelector {...defaultProps} />
</TestProviders>
);
expect(result.getByTestId('categories-filter-button')).toBeInTheDocument();
expect(result.getByText('Categories')).toBeInTheDocument();
expect(result.getByText(categoriesCount)).toBeInTheDocument();
});
it('should render the selector button with selected categories', () => {
const result = render(
<TestProviders>
<CategoriesSelector {...defaultProps} selectedCategoryIds={['base', 'event']} />
</TestProviders>
);
expect(result.getByTestId('categories-filter-button')).toBeInTheDocument();
expect(result.getByText('Categories')).toBeInTheDocument();
expect(result.getByText('2')).toBeInTheDocument();
});
it('should open the category selector', () => {
const result = render(
<TestProviders>
<CategoriesSelector {...defaultProps} />
</TestProviders>
);
result.getByTestId('categories-filter-button').click();
expect(result.getByTestId('categories-selector-search')).toBeInTheDocument();
expect(result.getByTestId(`categories-selector-option-base`)).toBeInTheDocument();
});
it('should open the category selector with selected categories', () => {
const result = render(
<TestProviders>
<CategoriesSelector {...defaultProps} selectedCategoryIds={['base', 'event']} />
</TestProviders>
);
result.getByTestId('categories-filter-button').click();
expect(result.getByTestId('categories-selector-search')).toBeInTheDocument();
expect(result.getByTestId(`categories-selector-option-base`)).toBeInTheDocument();
expect(result.getByTestId(`categories-selector-option-name-base`)).toHaveStyleRule(
'font-weight',
'bold'
);
});
it('should call setSelectedCategoryIds when category selected', () => {
const result = render(
<TestProviders>
<CategoriesSelector {...defaultProps} />
</TestProviders>
);
result.getByTestId('categories-filter-button').click();
result.getByTestId(`categories-selector-option-base`).click();
expect(mockSetSelectedCategoryIds).toHaveBeenCalledWith(['base']);
});
});

View file

@ -0,0 +1,173 @@
/*
* 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, useMemo, useState } from 'react';
import { omit } from 'lodash';
import {
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiHighlight,
EuiPopover,
EuiSelectable,
FilterChecked,
} from '@elastic/eui';
import { BrowserFields } from '../../../../../common';
import * as i18n from './translations';
import { CountBadge, getFieldCount, CategoryName, CategorySelectableContainer } from './helpers';
import { isEscape } from '../../../../../common/utils/accessibility';
interface CategoriesSelectorProps {
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/**
* Invoked when the user clicks on the name of a category in the left-hand
* side of the field browser
*/
setSelectedCategoryIds: (categoryIds: string[]) => void;
/** The category selected on the left-hand side of the field browser */
selectedCategoryIds: string[];
}
interface CategoryOption {
label: string;
count: number;
checked?: FilterChecked;
}
const renderOption = (option: CategoryOption, searchValue: string) => {
const { label, count, checked } = option;
// Some category names have spaces, but test selectors don't like spaces,
// Tests are not able to find subjects with spaces, so we need to clean them.
const idAttr = label.replace(/\s/g, '');
return (
<EuiFlexGroup
data-test-subj={`categories-selector-option-${idAttr}`}
alignItems="center"
gutterSize="none"
justifyContent="spaceBetween"
>
<EuiFlexItem grow={false}>
<CategoryName
data-test-subj={`categories-selector-option-name-${idAttr}`}
bold={checked === 'on'}
>
<EuiHighlight search={searchValue}>{label}</EuiHighlight>
</CategoryName>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CountBadge>{count}</CountBadge>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const CategoriesSelectorComponent: React.FC<CategoriesSelectorProps> = ({
filteredBrowserFields,
setSelectedCategoryIds,
selectedCategoryIds,
}) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const togglePopover = useCallback(() => {
setIsPopoverOpen((open) => !open);
}, []);
const closePopover = useCallback(() => {
setIsPopoverOpen(false);
}, []);
const totalCategories = useMemo(
() => Object.keys(filteredBrowserFields).length,
[filteredBrowserFields]
);
const categoryOptions: CategoryOption[] = useMemo(() => {
const unselectedCategoryIds = Object.keys(
omit(filteredBrowserFields, selectedCategoryIds)
).sort();
return [
...selectedCategoryIds.map((categoryId) => ({
label: categoryId,
count: getFieldCount(filteredBrowserFields[categoryId]),
checked: 'on',
})),
...unselectedCategoryIds.map((categoryId) => ({
label: categoryId,
count: getFieldCount(filteredBrowserFields[categoryId]),
})),
];
}, [selectedCategoryIds, filteredBrowserFields]);
const onCategoriesChange = useCallback(
(options: CategoryOption[]) => {
setSelectedCategoryIds(
options.filter(({ checked }) => checked === 'on').map(({ label }) => label)
);
},
[setSelectedCategoryIds]
);
const onKeyDown = useCallback((keyboardEvent: React.KeyboardEvent) => {
if (isEscape(keyboardEvent)) {
// Prevent escape to close the field browser modal after closing the category selector
keyboardEvent.stopPropagation();
}
}, []);
return (
<EuiFilterGroup data-test-subj="categories-selector">
<EuiPopover
button={
<EuiFilterButton
data-test-subj="categories-filter-button"
hasActiveFilters={selectedCategoryIds.length > 0}
iconType="arrowDown"
isSelected={isPopoverOpen}
numActiveFilters={selectedCategoryIds.length}
numFilters={totalCategories}
onClick={togglePopover}
>
{i18n.CATEGORIES}
</EuiFilterButton>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
>
<CategorySelectableContainer
onKeyDown={onKeyDown}
data-test-subj="categories-selector-container"
>
<EuiSelectable
aria-label="Searchable categories"
searchable
searchProps={{
'data-test-subj': 'categories-selector-search',
}}
options={categoryOptions}
renderOption={renderOption}
onChange={onCategoriesChange}
>
{(list, search) => (
<>
{search}
{list}
</>
)}
</EuiSelectable>
</CategorySelectableContainer>
</EuiPopover>
</EuiFilterGroup>
);
};
export const CategoriesSelector = React.memo(CategoriesSelectorComponent);

View file

@ -1,100 +0,0 @@
/*
* 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 { useMountAppended } from '../../../utils/use_mount_appended';
import { Category } from './category';
import { getFieldItems } from './field_items';
import { FIELDS_PANE_WIDTH } from './helpers';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import * as i18n from './translations';
describe('Category', () => {
const timelineId = 'test';
const selectedCategoryId = 'client';
const mount = useMountAppended();
test('it renders the category id as the value of the title', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="selected-category-title"]').first().text()).toEqual(
selectedCategoryId
);
});
test('it renders the Field column header', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('.euiTableCellContent__text').at(1).text()).toEqual(i18n.FIELD);
});
test('it renders the Description column header', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('.euiTableCellContent__text').at(2).text()).toEqual(i18n.DESCRIPTION);
});
});

View file

@ -1,114 +0,0 @@
/*
* 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 { EuiInMemoryTable } from '@elastic/eui';
import { noop } from 'lodash/fp';
import React, { useCallback, useMemo, useRef } from 'react';
import styled from 'styled-components';
import {
arrayIndexToAriaIndex,
DATA_COLINDEX_ATTRIBUTE,
DATA_ROWINDEX_ATTRIBUTE,
onKeyDownFocusHandler,
} from '../../../../../common/utils/accessibility';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { OnUpdateColumns } from '../../../../../common/types';
import { CategoryTitle } from './category_title';
import { getFieldColumns } from './field_items';
import type { FieldItem } from './field_items';
import { CATEGORY_TABLE_CLASS_NAME, TABLE_HEIGHT } from './helpers';
import * as i18n from './translations';
const TableContainer = styled.div<{ height: number; width: number }>`
${({ height }) => `height: ${height}px`};
${({ width }) => `width: ${width}px`};
overflow: hidden;
`;
TableContainer.displayName = 'TableContainer';
/**
* This callback, invoked via `EuiInMemoryTable`'s `rowProps, assigns
* attributes to every `<tr>`.
*/
const getAriaRowindex = (fieldItem: FieldItem) =>
fieldItem.ariaRowindex != null ? { 'data-rowindex': fieldItem.ariaRowindex } : {};
interface Props {
categoryId: string;
fieldItems: FieldItem[];
filteredBrowserFields: BrowserFields;
onCategorySelected: (categoryId: string) => void;
onUpdateColumns: OnUpdateColumns;
timelineId: string;
width: number;
}
export const Category = React.memo<Props>(
({ categoryId, filteredBrowserFields, fieldItems, onUpdateColumns, timelineId, width }) => {
const containerElement = useRef<HTMLDivElement | null>(null);
const onKeyDown = useCallback(
(keyboardEvent: React.KeyboardEvent) => {
onKeyDownFocusHandler({
colindexAttribute: DATA_COLINDEX_ATTRIBUTE,
containerElement: containerElement?.current,
event: keyboardEvent,
maxAriaColindex: 3,
maxAriaRowindex: fieldItems.length,
onColumnFocused: noop,
rowindexAttribute: DATA_ROWINDEX_ATTRIBUTE,
});
},
[fieldItems.length]
);
const fieldItemsWithRowindex = useMemo(
() =>
fieldItems.map((fieldItem, i) => ({
...fieldItem,
ariaRowindex: arrayIndexToAriaIndex(i),
})),
[fieldItems]
);
const columns = useMemo(() => getFieldColumns(), []);
return (
<>
<CategoryTitle
categoryId={categoryId}
filteredBrowserFields={filteredBrowserFields}
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
/>
<TableContainer
className="euiTable--compressed"
data-test-subj="category-table-container"
height={TABLE_HEIGHT}
onKeyDown={onKeyDown}
ref={containerElement}
width={width}
>
<EuiInMemoryTable
className={`${CATEGORY_TABLE_CLASS_NAME} eui-yScroll`}
items={fieldItemsWithRowindex}
columns={columns}
pagination={false}
rowProps={getAriaRowindex}
sorting={false}
tableCaption={i18n.CATEGORY_FIELDS_TABLE_CAPTION(categoryId)}
/>
</TableContainer>
</>
);
}
);
Category.displayName = 'Category';

View file

@ -1,153 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { CATEGORY_PANE_WIDTH, getFieldCount, VIEW_ALL_BUTTON_CLASS_NAME } from './helpers';
import { CategoriesPane } from './categories_pane';
import { ViewAllButton } from './category_columns';
const timelineId = 'test';
describe('getCategoryColumns', () => {
Object.keys(mockBrowserFields).forEach((categoryId) => {
test(`it renders the ${categoryId} category name (from filteredBrowserFields)`, () => {
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={''}
timelineId={timelineId}
/>
);
const fieldCount = Object.keys(mockBrowserFields[categoryId].fields ?? {}).length;
expect(
wrapper.find(`.field-browser-category-pane-${categoryId}-${timelineId}`).first().text()
).toEqual(`${categoryId}${fieldCount}`);
});
});
Object.keys(mockBrowserFields).forEach((categoryId) => {
test(`it renders the correct field count for the ${categoryId} category (from filteredBrowserFields)`, () => {
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={''}
timelineId={timelineId}
/>
);
expect(
wrapper.find(`[data-test-subj="${categoryId}-category-count"]`).first().text()
).toEqual(`${getFieldCount(mockBrowserFields[categoryId])}`);
});
});
test('it renders the selected category with bold text', () => {
const selectedCategoryId = 'auditd';
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={selectedCategoryId}
timelineId={timelineId}
/>
);
expect(
wrapper
.find(`.field-browser-category-pane-${selectedCategoryId}-${timelineId}`)
.find('[data-test-subj="categoryName"]')
.at(1)
).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' });
});
test('it does NOT render an un-selected category with bold text', () => {
const selectedCategoryId = 'auditd';
const notTheSelectedCategoryId = 'base';
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={jest.fn()}
selectedCategoryId={selectedCategoryId}
timelineId={timelineId}
/>
);
expect(
wrapper
.find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`)
.find('[data-test-subj="categoryName"]')
.at(1)
).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' });
});
test('it invokes onCategorySelected when a user clicks a category', () => {
const selectedCategoryId = 'auditd';
const notTheSelectedCategoryId = 'base';
const onCategorySelected = jest.fn();
const wrapper = mount(
<CategoriesPane
filteredBrowserFields={mockBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={onCategorySelected}
selectedCategoryId={selectedCategoryId}
timelineId={timelineId}
/>
);
wrapper
.find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`)
.first()
.simulate('click');
expect(onCategorySelected).toHaveBeenCalledWith(notTheSelectedCategoryId);
});
});
describe('ViewAllButton', () => {
it(`should update fields with the timestamp and category fields`, () => {
const onUpdateColumns = jest.fn();
const wrapper = mount(
<TestProviders>
<ViewAllButton
browserFields={{ agent: mockBrowserFields.agent }}
categoryId="agent"
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper.find(`.${VIEW_ALL_BUTTON_CLASS_NAME}`).first().simulate('click');
expect(onUpdateColumns).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ id: '@timestamp' }),
expect.objectContaining({ id: 'agent.ephemeral_id' }),
expect.objectContaining({ id: 'agent.hostname' }),
expect.objectContaining({ id: 'agent.id' }),
expect.objectContaining({ id: 'agent.name' }),
])
);
});
});

View file

@ -1,157 +0,0 @@
/*
* 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 {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiText,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { useDeepEqualSelector } from '../../../../hooks/use_selector';
import {
LoadingSpinner,
getCategoryPaneCategoryClassName,
getFieldCount,
VIEW_ALL_BUTTON_CLASS_NAME,
CountBadge,
} from './helpers';
import * as i18n from './translations';
import { tGridSelectors } from '../../../../store/t_grid';
import { getColumnsWithTimestamp } from '../../../utils/helpers';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { OnUpdateColumns } from '../../../../../common/types';
const CategoryName = styled.span<{ bold: boolean }>`
.euiText {
font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')};
}
`;
CategoryName.displayName = 'CategoryName';
const LinkContainer = styled.div`
width: 100%;
.euiLink {
width: 100%;
}
`;
LinkContainer.displayName = 'LinkContainer';
const ViewAll = styled(EuiButtonIcon)`
margin-left: 2px;
`;
ViewAll.displayName = 'ViewAll';
export interface CategoryItem {
categoryId: string;
}
interface ViewAllButtonProps {
categoryId: string;
browserFields: BrowserFields;
onUpdateColumns: OnUpdateColumns;
timelineId: string;
}
export const ViewAllButton = React.memo<ViewAllButtonProps>(
({ categoryId, browserFields, onUpdateColumns, timelineId }) => {
const getManageTimeline = useMemo(() => tGridSelectors.getManageTimelineById(), []);
const { isLoading } = useDeepEqualSelector((state) =>
getManageTimeline(state, timelineId ?? '')
);
const handleClick = useCallback(() => {
onUpdateColumns(
getColumnsWithTimestamp({
browserFields,
category: categoryId,
})
);
}, [browserFields, categoryId, onUpdateColumns]);
return (
<EuiToolTip content={i18n.VIEW_ALL_CATEGORY_FIELDS(categoryId)}>
{!isLoading ? (
<ViewAll
aria-label={i18n.VIEW_ALL_CATEGORY_FIELDS(categoryId)}
className={VIEW_ALL_BUTTON_CLASS_NAME}
onClick={handleClick}
iconType="visTable"
/>
) : (
<LoadingSpinner size="m" />
)}
</EuiToolTip>
);
}
);
ViewAllButton.displayName = 'ViewAllButton';
/**
* Returns the column definition for the (single) column that displays all the
* category names in the field browser */
export const getCategoryColumns = ({
filteredBrowserFields,
onCategorySelected,
selectedCategoryId,
timelineId,
}: {
filteredBrowserFields: BrowserFields;
onCategorySelected: (categoryId: string) => void;
selectedCategoryId: string;
timelineId: string;
}) => [
{
field: 'categoryId',
name: '',
sortable: true,
truncateText: false,
render: (
categoryId: string,
{ ariaRowindex }: { categoryId: string; ariaRowindex: number }
) => (
<LinkContainer>
<EuiLink
aria-label={i18n.CATEGORY_LINK({
category: categoryId,
totalCount: getFieldCount(filteredBrowserFields[categoryId]),
})}
className={getCategoryPaneCategoryClassName({
categoryId,
timelineId,
})}
data-test-subj="category-link"
data-colindex={1}
data-rowindex={ariaRowindex}
onClick={() => onCategorySelected(categoryId)}
>
<EuiFlexGroup alignItems="center" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<CategoryName data-test-subj="categoryName" bold={categoryId === selectedCategoryId}>
<EuiText size="xs">{categoryId}</EuiText>
</CategoryName>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CountBadge data-test-subj={`${categoryId}-category-count`} color="hollow">
{getFieldCount(filteredBrowserFields[categoryId])}
</CountBadge>
</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</LinkContainer>
),
},
];

View file

@ -1,72 +0,0 @@
/*
* 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 { mount } from 'enzyme';
import React from 'react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { CategoryTitle } from './category_title';
import { getFieldCount } from './helpers';
describe('CategoryTitle', () => {
const timelineId = 'test';
test('it renders the category id as the value of the title', () => {
const categoryId = 'client';
const wrapper = mount(
<TestProviders>
<CategoryTitle
categoryId={categoryId}
filteredBrowserFields={mockBrowserFields}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="selected-category-title"]').first().text()).toEqual(
categoryId
);
});
test('when `categoryId` specifies a valid category in `filteredBrowserFields`, a count of the field is displayed in the badge', () => {
const validCategoryId = 'client';
const wrapper = mount(
<TestProviders>
<CategoryTitle
categoryId={validCategoryId}
filteredBrowserFields={mockBrowserFields}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual(
`${getFieldCount(mockBrowserFields[validCategoryId])}`
);
});
test('when `categoryId` specifies an INVALID category in `filteredBrowserFields`, a count of zero is displayed in the badge', () => {
const invalidCategoryId = 'this.is.not.happening';
const wrapper = mount(
<TestProviders>
<CategoryTitle
categoryId={invalidCategoryId}
filteredBrowserFields={mockBrowserFields}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="selected-category-count-badge"]`).first().text()).toEqual(
'0'
);
});
});

View file

@ -1,67 +0,0 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiScreenReaderOnly, EuiTitle } from '@elastic/eui';
import React from 'react';
import { CountBadge, getFieldBrowserCategoryTitleClassName, getFieldCount } from './helpers';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { OnUpdateColumns } from '../../../../../common/types';
import { ViewAllButton } from './category_columns';
import * as i18n from './translations';
interface Props {
/** The title of the category */
categoryId: string;
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
onUpdateColumns: OnUpdateColumns;
/** The timeline associated with this field browser */
timelineId: string;
}
export const CategoryTitle = React.memo<Props>(
({ filteredBrowserFields, categoryId, onUpdateColumns, timelineId }) => (
<EuiFlexGroup alignItems="center" data-test-subj="category-title-container" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiScreenReaderOnly data-test-subj="screenReaderOnlyCategory">
<p>{i18n.CATEGORY}</p>
</EuiScreenReaderOnly>
<EuiTitle
className={getFieldBrowserCategoryTitleClassName({ categoryId, timelineId })}
data-test-subj="selected-category-title"
size="xxs"
>
<h3>{categoryId}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CountBadge data-test-subj="selected-category-count-badge" color="hollow">
{getFieldCount(filteredBrowserFields[categoryId])}
</CountBadge>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ViewAllButton
categoryId={categoryId}
browserFields={filteredBrowserFields}
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
/>
</EuiFlexItem>
</EuiFlexGroup>
)
);
CategoryTitle.displayName = 'CategoryTitle';

View file

@ -34,18 +34,20 @@ const testProps = {
searchInput: '',
appliedFilterInput: '',
isSearching: false,
onCategorySelected: jest.fn(),
setSelectedCategoryIds: jest.fn(),
onHide,
onSearchInputChange: jest.fn(),
restoreFocusTo: React.createRef<HTMLButtonElement>(),
selectedCategoryId: '',
selectedCategoryIds: [],
timelineId,
};
const { storage } = createSecuritySolutionStorageMock();
describe('FieldsBrowser', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
test('it renders the Close button', () => {
const wrapper = mount(
<TestProviders>
@ -80,20 +82,7 @@ describe('FieldsBrowser', () => {
test('it invokes updateColumns action when the user clicks the Reset Fields button', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser
columnHeaders={defaultHeaders}
browserFields={mockBrowserFields}
filteredBrowserFields={mockBrowserFields}
searchInput={''}
appliedFilterInput={''}
isSearching={false}
onCategorySelected={jest.fn()}
onHide={jest.fn()}
onSearchInputChange={jest.fn()}
restoreFocusTo={React.createRef<HTMLButtonElement>()}
selectedCategoryId={''}
timelineId={timelineId}
/>
<FieldsBrowser {...testProps} columnHeaders={defaultHeaders} />
</TestProviders>
);
@ -129,24 +118,24 @@ describe('FieldsBrowser', () => {
expect(wrapper.find('[data-test-subj="field-search"]').exists()).toBe(true);
});
test('it renders the categories pane', () => {
test('it renders the categories selector', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser {...testProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="left-categories-pane"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="categories-selector"]').exists()).toBe(true);
});
test('it renders the fields pane', () => {
test('it renders the fields table', () => {
const wrapper = mount(
<TestProviders>
<FieldsBrowser {...testProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="fields-pane"]').exists()).toBe(true);
expect(wrapper.find('[data-test-subj="field-table"]').exists()).toBe(true);
});
test('focuses the search input when the component mounts', () => {
@ -183,19 +172,24 @@ describe('FieldsBrowser', () => {
expect(onSearchInputChange).toBeCalledWith(inputText);
});
test('does not render the CreateField button when createFieldComponent is provided without a dataViewId', () => {
test('does not render the CreateFieldButton when it is provided but does not have a dataViewId', () => {
const MyTestComponent = () => <div>{'test'}</div>;
const wrapper = mount(
<TestProviders>
<FieldsBrowser {...testProps} createFieldComponent={MyTestComponent} />
<FieldsBrowser
{...testProps}
options={{
createFieldButton: MyTestComponent,
}}
/>
</TestProviders>
);
expect(wrapper.find(MyTestComponent).exists()).toBeFalsy();
});
test('it renders the CreateField button when createFieldComponent is provided with a dataViewId', () => {
test('it renders the CreateFieldButton when it is provided and have a dataViewId', () => {
const state: State = {
...mockGlobalState,
timelineById: {
@ -212,7 +206,12 @@ describe('FieldsBrowser', () => {
const wrapper = mount(
<TestProviders store={store}>
<FieldsBrowser {...testProps} createFieldComponent={MyTestComponent} />
<FieldsBrowser
{...testProps}
options={{
createFieldButton: MyTestComponent,
}}
/>
</TestProviders>
);

View file

@ -17,51 +17,27 @@ import {
EuiButtonEmpty,
EuiSpacer,
} from '@elastic/eui';
import React, { useEffect, useCallback, useRef, useMemo } from 'react';
import styled from 'styled-components';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { ColumnHeaderOptions, CreateFieldComponentType } from '../../../../../common/types';
import {
isEscape,
isTab,
stopPropagationAndPreventDefault,
} from '../../../../../common/utils/accessibility';
import { CategoriesPane } from './categories_pane';
import { FieldsPane } from './fields_pane';
import type { FieldBrowserProps, ColumnHeaderOptions } from '../../../../../common/types';
import { Search } from './search';
import {
CATEGORY_PANE_WIDTH,
CLOSE_BUTTON_CLASS_NAME,
FIELDS_PANE_WIDTH,
FIELD_BROWSER_WIDTH,
focusSearchInput,
onFieldsBrowserTabPressed,
PANES_FLEX_GROUP_WIDTH,
RESET_FIELDS_CLASS_NAME,
scrollCategoriesPane,
} from './helpers';
import type { FieldBrowserProps } from './types';
import { CLOSE_BUTTON_CLASS_NAME, FIELD_BROWSER_WIDTH, RESET_FIELDS_CLASS_NAME } from './helpers';
import { tGridActions, tGridSelectors } from '../../../../store/t_grid';
import * as i18n from './translations';
import { useDeepEqualSelector } from '../../../../hooks/use_selector';
import { CategoriesSelector } from './categories_selector';
import { FieldTable } from './field_table';
import { CategoriesBadges } from './categories_badges';
const PanesFlexGroup = styled(EuiFlexGroup)`
width: ${PANES_FLEX_GROUP_WIDTH}px;
`;
PanesFlexGroup.displayName = 'PanesFlexGroup';
type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width'> & {
type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width' | 'options'> & {
/**
* The current timeline column headers
*/
columnHeaders: ColumnHeaderOptions[];
createFieldComponent?: CreateFieldComponentType;
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
@ -80,12 +56,12 @@ type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width'> &
/**
* The category selected on the left-hand side of the field browser
*/
selectedCategoryId: string;
selectedCategoryIds: string[];
/**
* Invoked when the user clicks on the name of a category in the left-hand
* side of the field browser
*/
onCategorySelected: (categoryId: string) => void;
setSelectedCategoryIds: (categoryIds: string[]) => void;
/**
* Hides the field browser when invoked
*/
@ -110,23 +86,23 @@ type Props = Pick<FieldBrowserProps, 'timelineId' | 'browserFields' | 'width'> &
const FieldsBrowserComponent: React.FC<Props> = ({
columnHeaders,
filteredBrowserFields,
createFieldComponent: CreateField,
isSearching,
onCategorySelected,
setSelectedCategoryIds,
onSearchInputChange,
onHide,
options,
restoreFocusTo,
searchInput,
appliedFilterInput,
selectedCategoryId,
selectedCategoryIds,
timelineId,
width = FIELD_BROWSER_WIDTH,
}) => {
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const onUpdateColumns = useCallback(
(columns) => dispatch(tGridActions.updateColumns({ id: timelineId, columns })),
(columns: ColumnHeaderOptions[]) =>
dispatch(tGridActions.updateColumns({ id: timelineId, columns })),
[dispatch, timelineId]
);
@ -156,45 +132,14 @@ const FieldsBrowserComponent: React.FC<Props> = ({
[onSearchInputChange]
);
const scrollViewsAndFocusInput = useCallback(() => {
scrollCategoriesPane({
containerElement: containerElement.current,
selectedCategoryId,
timelineId,
});
// always re-focus the input to enable additional filtering
focusSearchInput({
containerElement: containerElement.current,
timelineId,
});
}, [selectedCategoryId, timelineId]);
useEffect(() => {
scrollViewsAndFocusInput();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCategoryId, timelineId]);
const onKeyDown = useCallback(
(keyboardEvent: React.KeyboardEvent) => {
if (isEscape(keyboardEvent)) {
stopPropagationAndPreventDefault(keyboardEvent);
closeAndRestoreFocus();
} else if (isTab(keyboardEvent)) {
onFieldsBrowserTabPressed({
containerElement: containerElement.current,
keyboardEvent,
selectedCategoryId,
timelineId,
});
}
},
[closeAndRestoreFocus, containerElement, selectedCategoryId, timelineId]
);
const [CreateFieldButton, getFieldTableColumns] = [
options?.createFieldButton,
options?.getFieldTableColumns,
];
return (
<EuiModal onClose={closeAndRestoreFocus} style={{ width, maxWidth: width }}>
<div data-test-subj="fields-browser-container" onKeyDown={onKeyDown} ref={containerElement}>
<div data-test-subj="fields-browser-container" className="eui-yScroll">
<EuiModalHeader>
<EuiModalHeaderTitle>
<h1>{i18n.FIELDS_BROWSER}</h1>
@ -202,11 +147,10 @@ const FieldsBrowserComponent: React.FC<Props> = ({
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup gutterSize="none">
<EuiFlexGroup gutterSize="m">
<EuiFlexItem>
<Search
data-test-subj="header"
filteredBrowserFields={filteredBrowserFields}
isSearching={isSearching}
onSearchInputChange={onInputChange}
searchInput={searchInput}
@ -214,39 +158,34 @@ const FieldsBrowserComponent: React.FC<Props> = ({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateField && dataViewId != null && dataViewId.length > 0 && (
<CreateField onClick={onHide} />
<CategoriesSelector
filteredBrowserFields={filteredBrowserFields}
setSelectedCategoryIds={setSelectedCategoryIds}
selectedCategoryIds={selectedCategoryIds}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{CreateFieldButton && dataViewId != null && dataViewId.length > 0 && (
<CreateFieldButton onClick={onHide} />
)}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<PanesFlexGroup alignItems="flexStart" gutterSize="none" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<CategoriesPane
data-test-subj="left-categories-pane"
filteredBrowserFields={filteredBrowserFields}
width={CATEGORY_PANE_WIDTH}
onCategorySelected={onCategorySelected}
selectedCategoryId={selectedCategoryId}
timelineId={timelineId}
/>
</EuiFlexItem>
<CategoriesBadges
selectedCategoryIds={selectedCategoryIds}
setSelectedCategoryIds={setSelectedCategoryIds}
/>
<EuiFlexItem grow={false}>
<FieldsPane
columnHeaders={columnHeaders}
data-test-subj="fields-pane"
filteredBrowserFields={filteredBrowserFields}
onCategorySelected={onCategorySelected}
onUpdateColumns={onUpdateColumns}
searchInput={appliedFilterInput}
selectedCategoryId={selectedCategoryId}
timelineId={timelineId}
width={FIELDS_PANE_WIDTH}
/>
</EuiFlexItem>
</PanesFlexGroup>
<EuiSpacer size="l" />
<FieldTable
timelineId={timelineId}
columnHeaders={columnHeaders}
filteredBrowserFields={filteredBrowserFields}
searchInput={appliedFilterInput}
selectedCategoryIds={selectedCategoryIds}
getFieldTableColumns={getFieldTableColumns}
/>
</EuiModalBody>
<EuiModalFooter>

View file

@ -5,21 +5,17 @@
* 2.0.
*/
import { omit } from 'lodash/fp';
import React from 'react';
import { waitFor } from '@testing-library/react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { omit } from 'lodash/fp';
import { render } from '@testing-library/react';
import { EuiInMemoryTable } from '@elastic/eui';
import { mockBrowserFields } from '../../../../mock';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants';
import { Category } from './category';
import { getFieldColumns, getFieldItems } from './field_items';
import { FIELDS_PANE_WIDTH } from './helpers';
import { useMountAppended } from '../../../utils/use_mount_appended';
import { ColumnHeaderOptions } from '../../../../../common/types';
const selectedCategoryId = 'base';
const selectedCategoryFields = mockBrowserFields[selectedCategoryId].fields;
const timestampFieldId = '@timestamp';
const columnHeaders: ColumnHeaderOptions[] = [
{
@ -28,7 +24,7 @@ const columnHeaders: ColumnHeaderOptions[] = [
description:
'Date/time when the event originated.\nFor log events this is the date/time when the event was generated, and not when it was read.\nRequired field for all events.',
example: '2016-05-23T08:05:34.853Z',
id: '@timestamp',
id: timestampFieldId,
type: 'date',
aggregatable: true,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
@ -36,295 +32,199 @@ const columnHeaders: ColumnHeaderOptions[] = [
];
describe('field_items', () => {
const timelineId = 'test';
const mount = useMountAppended();
describe('getFieldItems', () => {
Object.keys(selectedCategoryFields!).forEach((fieldId) => {
test(`it renders the name of the ${fieldId} field`, () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
const timestampField = mockBrowserFields.base.fields![timestampFieldId];
expect(wrapper.find(`[data-test-subj="field-name-${fieldId}"]`).first().text()).toEqual(
fieldId
);
it('should return browser field item format', () => {
const fieldItems = getFieldItems({
selectedCategoryIds: ['base'],
browserFields: { base: { fields: { [timestampFieldId]: timestampField } } },
columnHeaders: [],
});
expect(fieldItems[0]).toEqual({
name: timestampFieldId,
description: timestampField.description,
category: 'base',
selected: false,
type: timestampField.type,
example: timestampField.example,
isRuntime: false,
});
});
Object.keys(selectedCategoryFields!).forEach((fieldId) => {
test(`it renders a checkbox for the ${fieldId} field`, () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
it('should return selected item', () => {
const fieldItems = getFieldItems({
selectedCategoryIds: ['base'],
browserFields: { base: { fields: { [timestampFieldId]: timestampField } } },
columnHeaders,
});
expect(wrapper.find(`[data-test-subj="field-${fieldId}-checkbox"]`).first().exists()).toBe(
true
);
expect(fieldItems[0]).toMatchObject({
selected: true,
});
});
test('it renders a checkbox in the checked state when the field is selected to be displayed as a column in the timeline', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders,
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`).first().props()
.checked
).toBe(true);
});
test('it does NOT render a checkbox in the checked state when the field is NOT selected to be displayed as a column in the timeline', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: columnHeaders.filter((header) => header.id !== timestampFieldId),
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
expect(
wrapper.find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`).first().props()
.checked
).toBe(false);
});
test('it invokes `toggleColumn` when the user interacts with the checkbox', () => {
const toggleColumn = jest.fn();
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders: [],
highlight: '',
timelineId,
toggleColumn,
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find('input[type="checkbox"]')
.first()
.simulate('change', {
target: { checked: true },
});
wrapper.update();
expect(toggleColumn).toBeCalledWith({
columnHeaderType: 'not-filtered',
id: '@timestamp',
initialWidth: 180,
});
});
test('it returns the expected signal column settings', async () => {
const mockSelectedCategoryId = 'signal';
const mockBrowserFieldsWithSignal = {
...mockBrowserFields,
signal: {
fields: {
'signal.rule.name': {
aggregatable: true,
category: 'signal',
description: 'rule name',
example: '',
format: '',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'signal.rule.name',
searchable: true,
type: 'string',
it('should return isRuntime field', () => {
const fieldItems = getFieldItems({
selectedCategoryIds: ['base'],
browserFields: {
base: {
fields: {
[timestampFieldId]: {
...timestampField,
runtimeField: { type: 'keyword', script: { source: 'scripts are fun' } },
},
},
},
},
};
const toggleColumn = jest.fn();
const wrapper = mount(
<TestProviders>
<Category
categoryId={mockSelectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFieldsWithSignal}
fieldItems={getFieldItems({
category: mockBrowserFieldsWithSignal[mockSelectedCategoryId],
columnHeaders,
highlight: '',
timelineId,
toggleColumn,
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper
.find(`[data-test-subj="field-signal.rule.name-checkbox"]`)
.last()
.simulate('change', {
target: { checked: true },
});
columnHeaders,
});
await waitFor(() => {
expect(toggleColumn).toBeCalledWith({
columnHeaderType: 'not-filtered',
id: 'signal.rule.name',
initialWidth: 180,
});
expect(fieldItems[0]).toMatchObject({
isRuntime: true,
});
});
test('it renders the expected icon for a field', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders,
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
it('should return all field items of all categories if no category selected', () => {
const fieldCount = Object.values(mockBrowserFields).reduce(
(total, { fields }) => total + Object.keys(fields ?? {}).length,
0
);
expect(
wrapper.find(`[data-test-subj="field-${timestampFieldId}-icon"]`).first().props().type
).toEqual('clock');
const fieldItems = getFieldItems({
selectedCategoryIds: [],
browserFields: mockBrowserFields,
columnHeaders: [],
});
expect(fieldItems.length).toBe(fieldCount);
});
test('it renders the expected field description', () => {
const wrapper = mount(
<TestProviders>
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={mockBrowserFields}
fieldItems={getFieldItems({
category: mockBrowserFields[selectedCategoryId],
columnHeaders,
highlight: '',
timelineId,
toggleColumn: jest.fn(),
})}
width={FIELDS_PANE_WIDTH}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
timelineId={timelineId}
/>
</TestProviders>
it('should return filtered field items of selected categories', () => {
const selectedCategoryIds = ['base', 'event'];
const fieldCount = selectedCategoryIds.reduce(
(total, selectedCategoryId) =>
total + Object.keys(mockBrowserFields[selectedCategoryId].fields ?? {}).length,
0
);
expect(
wrapper.find(`[data-test-subj="field-${timestampFieldId}-description"]`).first().text()
).toEqual(
'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'
);
const fieldItems = getFieldItems({
selectedCategoryIds,
browserFields: mockBrowserFields,
columnHeaders: [],
});
expect(fieldItems.length).toBe(fieldCount);
});
});
describe('getFieldColumns', () => {
test('it returns the expected column definitions', () => {
expect(getFieldColumns().map((column) => omit('render', column))).toEqual([
const onToggleColumn = jest.fn();
beforeEach(() => {
onToggleColumn.mockClear();
});
it('should return default field columns', () => {
expect(getFieldColumns({ onToggleColumn }).map((column) => omit('render', column))).toEqual([
{
field: 'checkbox',
field: 'selected',
name: '',
sortable: false,
width: '25px',
},
{ field: 'field', name: 'Field', sortable: false, width: '225px' },
{
field: 'name',
name: 'Name',
sortable: true,
width: '225px',
},
{
field: 'description',
name: 'Description',
sortable: false,
truncateText: true,
sortable: true,
width: '400px',
},
{
field: 'category',
name: 'Category',
sortable: true,
width: '100px',
},
]);
});
it('should return custom field columns', () => {
const customColumns = [
{
field: 'name',
name: 'customColumn1',
sortable: false,
width: '225px',
},
{
field: 'description',
name: 'customColumn2',
sortable: true,
width: '400px',
},
];
expect(
getFieldColumns({
onToggleColumn,
getFieldTableColumns: () => customColumns,
}).map((column) => omit('render', column))
).toEqual([
{
field: 'selected',
name: '',
sortable: false,
width: '25px',
},
...customColumns,
]);
});
it('should render default columns', () => {
const timestampField = mockBrowserFields.base.fields![timestampFieldId];
const fieldItems = getFieldItems({
selectedCategoryIds: ['base'],
browserFields: { base: { fields: { [timestampFieldId]: timestampField } } },
columnHeaders: [],
});
const columns = getFieldColumns({ onToggleColumn });
const { getByTestId, getAllByText } = render(
<EuiInMemoryTable items={fieldItems} itemId="name" columns={columns} />
);
expect(getAllByText('Name').at(0)).toBeInTheDocument();
expect(getAllByText('Description').at(0)).toBeInTheDocument();
expect(getAllByText('Category').at(0)).toBeInTheDocument();
expect(getByTestId(`field-${timestampFieldId}-checkbox`)).toBeInTheDocument();
expect(getByTestId(`field-${timestampFieldId}-name`)).toBeInTheDocument();
expect(getByTestId(`field-${timestampFieldId}-description`)).toBeInTheDocument();
expect(getByTestId(`field-${timestampFieldId}-category`)).toBeInTheDocument();
});
it('should call call toggle callback on checkbox click', () => {
const timestampField = mockBrowserFields.base.fields![timestampFieldId];
const fieldItems = getFieldItems({
selectedCategoryIds: ['base'],
browserFields: { base: { fields: { [timestampFieldId]: timestampField } } },
columnHeaders: [],
});
const columns = getFieldColumns({ onToggleColumn });
const { getByTestId } = render(
<EuiInMemoryTable items={fieldItems} itemId="name" columns={columns} />
);
getByTestId(`field-${timestampFieldId}-checkbox`).click();
expect(onToggleColumn).toHaveBeenCalledWith(timestampFieldId);
});
});
});

View file

@ -13,14 +13,22 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiScreenReaderOnly,
EuiBadge,
EuiBasicTableColumn,
EuiTableActionsColumnType,
} from '@elastic/eui';
import { uniqBy } from 'lodash/fp';
import styled from 'styled-components';
import { getEmptyValue } from '../../../empty_value';
import { getExampleText, getIconFromType } from '../../../utils/helpers';
import type { BrowserField } from '../../../../../common/search_strategy';
import type { ColumnHeaderOptions } from '../../../../../common/types';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type {
ColumnHeaderOptions,
BrowserFieldItem,
FieldTableColumns,
GetFieldTableColumns,
} from '../../../../../common/types';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../body/constants';
import { TruncatableText } from '../../../truncatable_text';
@ -33,125 +41,155 @@ const TypeIcon = styled(EuiIcon)`
position: relative;
top: -1px;
`;
TypeIcon.displayName = 'TypeIcon';
export const Description = styled.span`
user-select: text;
width: 400px;
`;
Description.displayName = 'Description';
/**
* An item rendered in the table
*/
export interface FieldItem {
ariaRowindex?: number;
checkbox: React.ReactNode;
description: React.ReactNode;
field: React.ReactNode;
fieldId: string;
}
/**
* Returns the fields items, values, and descriptions shown when a user expands an event
* Returns the field items of all categories selected
*/
export const getFieldItems = ({
category,
browserFields,
selectedCategoryIds,
columnHeaders,
highlight = '',
timelineId,
toggleColumn,
}: {
category: Partial<BrowserField>;
browserFields: BrowserFields;
selectedCategoryIds: string[];
columnHeaders: ColumnHeaderOptions[];
highlight?: string;
timelineId: string;
toggleColumn: (column: ColumnHeaderOptions) => void;
}): FieldItem[] =>
uniqBy('name', [
...Object.values(category != null && category.fields != null ? category.fields : {}),
]).map((field) => ({
checkbox: (
<EuiToolTip content={i18n.VIEW_COLUMN(field.name ?? '')}>
<EuiCheckbox
aria-label={i18n.VIEW_COLUMN(field.name ?? '')}
checked={columnHeaders.findIndex((c) => c.id === field.name) !== -1}
data-test-subj={`field-${field.name}-checkbox`}
data-colindex={1}
id={field.name ?? ''}
onChange={() =>
toggleColumn({
columnHeaderType: defaultColumnHeaderType,
id: field.name ?? '',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...getAlertColumnHeader(timelineId, field.name ?? ''),
})
}
/>
</EuiToolTip>
),
field: (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={field.type}>
<TypeIcon
data-test-subj={`field-${field.name}-icon`}
type={getIconFromType(field.type ?? null)}
/>
</EuiToolTip>
</EuiFlexItem>
}): BrowserFieldItem[] => {
const categoryIds =
selectedCategoryIds.length > 0 ? selectedCategoryIds : Object.keys(browserFields);
const selectedFieldIds = new Set(columnHeaders.map(({ id }) => id));
<EuiFlexItem grow={false}>
<FieldName data-test-subj="field-name" fieldId={field.name ?? ''} highlight={highlight} />
</EuiFlexItem>
</EuiFlexGroup>
),
description: (
<div data-colindex={3} tabIndex={0}>
<EuiToolTip content={field.description}>
<>
<EuiScreenReaderOnly data-test-subj="descriptionForScreenReaderOnly">
<p>{i18n.DESCRIPTION_FOR_FIELD(field.name ?? '')}</p>
</EuiScreenReaderOnly>
<TruncatableText>
<Description data-test-subj={`field-${field.name}-description`}>
{`${field.description ?? getEmptyValue()} ${getExampleText(field.example)}`}
</Description>
</TruncatableText>
</>
</EuiToolTip>
</div>
),
fieldId: field.name ?? '',
}));
return uniqBy(
'name',
categoryIds.reduce<BrowserFieldItem[]>((fieldItems, categoryId) => {
const categoryBrowserFields = Object.values(browserFields[categoryId]?.fields ?? {});
if (categoryBrowserFields.length > 0) {
fieldItems.push(
...categoryBrowserFields.map(({ name = '', ...field }) => ({
name,
type: field.type,
description: field.description ?? '',
example: field.example?.toString(),
category: categoryId,
selected: selectedFieldIds.has(name),
isRuntime: !!field.runtimeField,
}))
);
}
return fieldItems;
}, [])
);
};
/**
* Returns a table column template provided to the `EuiInMemoryTable`'s
* `columns` prop
* Returns the column header for a field
*/
export const getFieldColumns = () => [
export const getColumnHeader = (timelineId: string, fieldName: string): ColumnHeaderOptions => ({
columnHeaderType: defaultColumnHeaderType,
id: fieldName,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...getAlertColumnHeader(timelineId, fieldName),
});
const getDefaultFieldTableColumns = (highlight: string): FieldTableColumns => [
{
field: 'checkbox',
name: '',
render: (checkbox: React.ReactNode, _: FieldItem) => checkbox,
sortable: false,
width: '25px',
},
{
field: 'field',
name: i18n.FIELD,
render: (field: React.ReactNode, _: FieldItem) => field,
sortable: false,
field: 'name',
name: i18n.NAME,
render: (name: string, { type }) => {
return (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem grow={false}>
<EuiToolTip content={type}>
<TypeIcon
data-test-subj={`field-${name}-icon`}
type={getIconFromType(type ?? null)}
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FieldName fieldId={name} highlight={highlight} />
</EuiFlexItem>
</EuiFlexGroup>
);
},
sortable: true,
width: '225px',
},
{
field: 'description',
name: i18n.DESCRIPTION,
render: (description: React.ReactNode, _: FieldItem) => description,
sortable: false,
truncateText: true,
render: (description: string, { name, example }) => (
<EuiToolTip content={description}>
<>
<EuiScreenReaderOnly data-test-subj="descriptionForScreenReaderOnly">
<p>{i18n.DESCRIPTION_FOR_FIELD(name)}</p>
</EuiScreenReaderOnly>
<TruncatableText>
<Description data-test-subj={`field-${name}-description`}>
{`${description ?? getEmptyValue()} ${getExampleText(example)}`}
</Description>
</TruncatableText>
</>
</EuiToolTip>
),
sortable: true,
width: '400px',
},
{
field: 'category',
name: i18n.CATEGORY,
render: (category: string, { name }) => (
<EuiBadge data-test-subj={`field-${name}-category`}>{category}</EuiBadge>
),
sortable: true,
width: '100px',
},
];
/**
* Returns a table column template provided to the `EuiInMemoryTable`'s
* `columns` prop
*/
export const getFieldColumns = ({
onToggleColumn,
highlight = '',
getFieldTableColumns,
}: {
onToggleColumn: (id: string) => void;
highlight?: string;
getFieldTableColumns?: GetFieldTableColumns;
}): FieldTableColumns => [
{
field: 'selected',
name: '',
render: (selected: boolean, { name }) => (
<EuiToolTip content={i18n.VIEW_COLUMN(name)}>
<EuiCheckbox
aria-label={i18n.VIEW_COLUMN(name)}
checked={selected}
data-test-subj={`field-${name}-checkbox`}
data-colindex={1}
id={name}
onChange={() => onToggleColumn(name)}
/>
</EuiToolTip>
),
sortable: false,
width: '25px',
},
...(getFieldTableColumns
? getFieldTableColumns(highlight)
: getDefaultFieldTableColumns(highlight)),
];
/** Returns whether the table column has actions attached to it */
export const isActionsColumn = (column: EuiBasicTableColumn<BrowserFieldItem>): boolean => {
return !!(column as EuiTableActionsColumnType<BrowserFieldItem>).actions?.length;
};

View file

@ -43,7 +43,7 @@ describe('FieldName', () => {
);
expect(
wrapper.find(`[data-test-subj="field-name-${timestampFieldId}"]`).first().text()
wrapper.find(`[data-test-subj="field-${timestampFieldId}-name"]`).first().text()
).toEqual(timestampFieldId);
});

View file

@ -15,7 +15,7 @@ export const FieldName = React.memo<{
}>(({ fieldId, highlight = '' }) => {
return (
<EuiText size="xs">
<EuiHighlight data-test-subj={`field-name-${fieldId}`} search={highlight}>
<EuiHighlight data-test-subj={`field-${fieldId}-name`} search={highlight}>
{fieldId}
</EuiHighlight>
</EuiText>

View file

@ -0,0 +1,225 @@
/*
* 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 { mockBrowserFields, TestProviders } from '../../../../mock';
import { tGridActions } from '../../../../store/t_grid';
import { defaultColumnHeaderType } from '../../body/column_headers/default_headers';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../body/constants';
import { ColumnHeaderOptions } from '../../../../../common';
import { FieldTable } from './field_table';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const timestampFieldId = '@timestamp';
const columnHeaders: ColumnHeaderOptions[] = [
{
category: 'base',
columnHeaderType: defaultColumnHeaderType,
description:
'Date/time when the event originated.\nFor log events this is the date/time when the event was generated, and not when it was read.\nRequired field for all events.',
example: '2016-05-23T08:05:34.853Z',
id: timestampFieldId,
type: 'date',
aggregatable: true,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
},
];
describe('FieldTable', () => {
const timelineId = 'test';
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();
});
it('should render empty field table', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={{}}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
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', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
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', () => {
const selectedCategoryIds = ['client', 'event'];
const fieldCount = selectedCategoryIds.reduce(
(total, selectedCategoryId) =>
total + Object.keys(mockBrowserFields[selectedCategoryId].fields ?? {}).length,
0
);
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={selectedCategoryIds}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(result.container.getElementsByClassName('euiTableRow').length).toBe(fieldCount);
expect(result.getByTestId('fields-count').textContent).toContain(fieldCount);
});
it('should render field table with custom columns', () => {
const fieldTableColumns = [
{
field: 'name',
name: 'Custom column',
render: () => <div data-test-subj="customColumn" />,
},
];
const result = render(
<TestProviders>
<FieldTable
getFieldTableColumns={() => fieldTableColumns}
selectedCategoryIds={[]}
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(result.getByTestId('fields-count').textContent).toContain(totalFields);
expect(result.getAllByText('Custom column').length).toBeGreaterThan(0);
expect(result.getAllByTestId('customColumn').length).toEqual(defaultPageSize);
});
it('should render field table with unchecked field', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={['base']}
columnHeaders={[]}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
const checkbox = result.getByTestId(`field-${timestampFieldId}-checkbox`);
expect(checkbox).not.toHaveAttribute('checked');
});
it('should render field table with checked field', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={['base']}
columnHeaders={columnHeaders}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
const checkbox = result.getByTestId(`field-${timestampFieldId}-checkbox`);
expect(checkbox).toHaveAttribute('checked');
});
it('should dispatch remove column action on field unchecked', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={['base']}
columnHeaders={columnHeaders}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
result.getByTestId(`field-${timestampFieldId}-checkbox`).click();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
tGridActions.removeColumn({ id: timelineId, columnId: timestampFieldId })
);
});
it('should dispatch upsert column action on field checked', () => {
const result = render(
<TestProviders>
<FieldTable
selectedCategoryIds={['base']}
columnHeaders={[]}
filteredBrowserFields={{ base: { fields: { [timestampFieldId]: timestampField } } }}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
result.getByTestId(`field-${timestampFieldId}-checkbox`).click();
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch).toHaveBeenCalledWith(
tGridActions.upsertColumn({
id: timelineId,
column: {
columnHeaderType: defaultColumnHeaderType,
id: timestampFieldId,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
index: 1,
})
);
});
});

View file

@ -0,0 +1,126 @@
/*
* 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, useMemo } from 'react';
import styled from 'styled-components';
import { EuiInMemoryTable, EuiText } 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';
interface FieldTableProps {
timelineId: string;
columnHeaders: ColumnHeaderOptions[];
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/**
* Optional function to customize field table columns
*/
getFieldTableColumns?: GetFieldTableColumns;
/**
* The category selected on the left-hand side of the field browser
*/
selectedCategoryIds: string[];
/** The text displayed in the search input */
/** Invoked when a user chooses to view a new set of columns in the timeline */
searchInput: string;
}
const TableContainer = styled.div<{ height: number }>`
margin-top: ${({ theme }) => theme.eui.euiSizeXS};
border-top: ${({ theme }) => theme.eui.euiBorderThin};
${({ height }) => `height: ${height}px`};
overflow: hidden;
`;
TableContainer.displayName = 'TableContainer';
const Count = styled.span`
font-weight: bold;
`;
Count.displayName = 'Count';
const FieldTableComponent: React.FC<FieldTableProps> = ({
columnHeaders,
filteredBrowserFields,
getFieldTableColumns,
searchInput,
selectedCategoryIds,
timelineId,
}) => {
const dispatch = useDispatch();
const fieldItems = useMemo(
() =>
getFieldItems({
browserFields: filteredBrowserFields,
selectedCategoryIds,
columnHeaders,
}),
[columnHeaders, filteredBrowserFields, selectedCategoryIds]
);
const onToggleColumn = useCallback(
(fieldId: string) => {
if (columnHeaders.some(({ id }) => id === fieldId)) {
dispatch(
tGridActions.removeColumn({
columnId: fieldId,
id: timelineId,
})
);
} else {
dispatch(
tGridActions.upsertColumn({
column: getColumnHeader(timelineId, fieldId),
id: timelineId,
index: 1,
})
);
}
},
[columnHeaders, dispatch, timelineId]
);
const columns = useMemo(
() => getFieldColumns({ highlight: searchInput, onToggleColumn, getFieldTableColumns }),
[onToggleColumn, searchInput, getFieldTableColumns]
);
const hasActions = useMemo(() => columns.some((column) => isActionsColumn(column)), [columns]);
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>
<TableContainer className="euiTable--compressed" height={TABLE_HEIGHT}>
<EuiInMemoryTable
data-test-subj="field-table"
className={`${CATEGORY_TABLE_CLASS_NAME} eui-yScroll`}
items={fieldItems}
itemId="name"
columns={columns}
pagination={true}
sorting={true}
hasActions={hasActions}
/>
</TableContainer>
</>
);
};
export const FieldTable = React.memo(FieldTableComponent);

View file

@ -1,112 +0,0 @@
/*
* 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 { useMountAppended } from '../../../utils/use_mount_appended';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { FIELDS_PANE_WIDTH } from './helpers';
import { FieldsPane } from './fields_pane';
const timelineId = 'test';
describe('FieldsPane', () => {
const mount = useMountAppended();
test('it renders the selected category', () => {
const selectedCategory = 'auditd';
const wrapper = mount(
<TestProviders>
<FieldsPane
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
searchInput=""
selectedCategoryId={selectedCategory}
timelineId={timelineId}
width={FIELDS_PANE_WIDTH}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="selected-category-title"]`).first().text()).toEqual(
selectedCategory
);
});
test('it renders a unknown category that does not exist in filteredBrowserFields', () => {
const selectedCategory = 'unknown';
const wrapper = mount(
<TestProviders>
<FieldsPane
columnHeaders={[]}
filteredBrowserFields={mockBrowserFields}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
searchInput=""
selectedCategoryId={selectedCategory}
timelineId={timelineId}
width={FIELDS_PANE_WIDTH}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="selected-category-title"]`).first().text()).toEqual(
selectedCategory
);
});
test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is empty', () => {
const searchInput = '';
const wrapper = mount(
<TestProviders>
<FieldsPane
columnHeaders={[]}
filteredBrowserFields={{}}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
searchInput={searchInput}
selectedCategoryId=""
timelineId={timelineId}
width={FIELDS_PANE_WIDTH}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-fields-match"]`).first().text()).toEqual(
'No fields match '
);
});
test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is an unknown field name', () => {
const searchInput = 'thisFieldDoesNotExist';
const wrapper = mount(
<TestProviders>
<FieldsPane
columnHeaders={[]}
filteredBrowserFields={{}}
onCategorySelected={jest.fn()}
onUpdateColumns={jest.fn()}
searchInput={searchInput}
selectedCategoryId=""
timelineId={timelineId}
width={FIELDS_PANE_WIDTH}
/>
</TestProviders>
);
expect(wrapper.find(`[data-test-subj="no-fields-match"]`).first().text()).toEqual(
`No fields match ${searchInput}`
);
});
});

View file

@ -1,145 +0,0 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { useDispatch } from 'react-redux';
import { Category } from './category';
import { getFieldItems } from './field_items';
import { FIELDS_PANE_WIDTH, TABLE_HEIGHT } from './helpers';
import * as i18n from './translations';
import type { BrowserFields } from '../../../../../common/search_strategy';
import type { ColumnHeaderOptions, OnUpdateColumns } from '../../../../../common/types';
import { tGridActions } from '../../../../store/t_grid';
const NoFieldsPanel = styled.div`
background-color: ${(props) => props.theme.eui.euiColorLightestShade};
width: ${FIELDS_PANE_WIDTH}px;
height: ${TABLE_HEIGHT}px;
`;
NoFieldsPanel.displayName = 'NoFieldsPanel';
const NoFieldsFlexGroup = styled(EuiFlexGroup)`
height: 100%;
`;
NoFieldsFlexGroup.displayName = 'NoFieldsFlexGroup';
interface Props {
timelineId: string;
columnHeaders: ColumnHeaderOptions[];
/**
* A map of categoryId -> metadata about the fields in that category,
* filtered such that the name of every field in the category includes
* the filter input (as a substring).
*/
filteredBrowserFields: BrowserFields;
/**
* Invoked when the user clicks on the name of a category in the left-hand
* side of the field browser
*/
onCategorySelected: (categoryId: string) => void;
/** The text displayed in the search input */
/** Invoked when a user chooses to view a new set of columns in the timeline */
onUpdateColumns: OnUpdateColumns;
searchInput: string;
/**
* The category selected on the left-hand side of the field browser
*/
selectedCategoryId: string;
/** The width field browser */
width: number;
}
export const FieldsPane = React.memo<Props>(
({
columnHeaders,
filteredBrowserFields,
onCategorySelected,
onUpdateColumns,
searchInput,
selectedCategoryId,
timelineId,
width,
}) => {
const dispatch = useDispatch();
const toggleColumn = useCallback(
(column: ColumnHeaderOptions) => {
if (columnHeaders.some((c) => c.id === column.id)) {
dispatch(
tGridActions.removeColumn({
columnId: column.id,
id: timelineId,
})
);
} else {
dispatch(
tGridActions.upsertColumn({
column,
id: timelineId,
index: 1,
})
);
}
},
[columnHeaders, dispatch, timelineId]
);
const filteredBrowserFieldsExists = useMemo(
() => Object.keys(filteredBrowserFields).length > 0,
[filteredBrowserFields]
);
const fieldItems = useMemo(() => {
return getFieldItems({
category: filteredBrowserFields[selectedCategoryId],
columnHeaders,
highlight: searchInput,
timelineId,
toggleColumn,
});
}, [
columnHeaders,
filteredBrowserFields,
searchInput,
selectedCategoryId,
timelineId,
toggleColumn,
]);
if (filteredBrowserFieldsExists) {
return (
<Category
categoryId={selectedCategoryId}
data-test-subj="category"
filteredBrowserFields={filteredBrowserFields}
fieldItems={fieldItems}
width={width}
onCategorySelected={onCategorySelected}
onUpdateColumns={onUpdateColumns}
timelineId={timelineId}
/>
);
}
return (
<NoFieldsPanel>
<NoFieldsFlexGroup alignItems="center" gutterSize="none" justifyContent="center">
<EuiFlexItem grow={false}>
<h3 data-test-subj="no-fields-match">{i18n.NO_FIELDS_MATCH_INPUT(searchInput)}</h3>
</EuiFlexItem>
</NoFieldsFlexGroup>
</NoFieldsPanel>
);
}
);
FieldsPane.displayName = 'FieldsPane';

View file

@ -10,45 +10,12 @@ import { mockBrowserFields } from '../../../../mock';
import {
categoryHasFields,
createVirtualCategory,
getCategoryPaneCategoryClassName,
getFieldBrowserCategoryTitleClassName,
getFieldBrowserSearchInputClassName,
getFieldCount,
filterBrowserFieldsByFieldName,
} from './helpers';
import { BrowserFields } from '../../../../../common/search_strategy';
const timelineId = 'test';
describe('helpers', () => {
describe('getCategoryPaneCategoryClassName', () => {
test('it returns the expected class name', () => {
const categoryId = 'auditd';
expect(getCategoryPaneCategoryClassName({ categoryId, timelineId })).toEqual(
'field-browser-category-pane-auditd-test'
);
});
});
describe('getFieldBrowserCategoryTitleClassName', () => {
test('it returns the expected class name', () => {
const categoryId = 'auditd';
expect(getFieldBrowserCategoryTitleClassName({ categoryId, timelineId })).toEqual(
'field-browser-category-title-auditd-test'
);
});
});
describe('getFieldBrowserSearchInputClassName', () => {
test('it returns the expected class name', () => {
expect(getFieldBrowserSearchInputClassName(timelineId)).toEqual(
'field-browser-search-input-test'
);
});
});
describe('categoryHasFields', () => {
test('it returns false if the category fields property is undefined', () => {
expect(categoryHasFields({})).toBe(false);

View file

@ -9,11 +9,6 @@ import { EuiBadge, EuiLoadingSpinner } from '@elastic/eui';
import { filter, get, pickBy } from 'lodash/fp';
import styled from 'styled-components';
import {
elementOrChildrenHasFocus,
skipFocusInContainerTo,
stopPropagationAndPreventDefault,
} from '../../../../../common/utils/accessibility';
import { TimelineId } from '../../../../../public/types';
import type { BrowserField, BrowserFields } from '../../../../../common/search_strategy';
import { defaultHeaders } from '../../../../store/t_grid/defaults';
@ -27,44 +22,8 @@ export const LoadingSpinner = styled(EuiLoadingSpinner)`
LoadingSpinner.displayName = 'LoadingSpinner';
export const CATEGORY_PANE_WIDTH = 200;
export const DESCRIPTION_COLUMN_WIDTH = 300;
export const FIELD_COLUMN_WIDTH = 200;
export const FIELD_BROWSER_WIDTH = 925;
export const FIELDS_PANE_WIDTH = 670;
export const HEADER_HEIGHT = 40;
export const PANES_FLEX_GROUP_WIDTH = CATEGORY_PANE_WIDTH + FIELDS_PANE_WIDTH + 10;
export const PANES_FLEX_GROUP_HEIGHT = 260;
export const TABLE_HEIGHT = 260;
export const TYPE_COLUMN_WIDTH = 50;
/**
* Returns the CSS class name for the title of a category shown in the left
* side field browser
*/
export const getCategoryPaneCategoryClassName = ({
categoryId,
timelineId,
}: {
categoryId: string;
timelineId: string;
}): string => `field-browser-category-pane-${categoryId}-${timelineId}`;
/**
* Returns the CSS class name for the title of a category shown in the right
* side of field browser
*/
export const getFieldBrowserCategoryTitleClassName = ({
categoryId,
timelineId,
}: {
categoryId: string;
timelineId: string;
}): string => `field-browser-category-title-${categoryId}-${timelineId}`;
/** Returns the class name for a field browser search input */
export const getFieldBrowserSearchInputClassName = (timelineId: string): string =>
`field-browser-search-input-${timelineId}`;
/** Returns true if the specified category has at least one field */
export const categoryHasFields = (category: Partial<BrowserField>): boolean =>
@ -160,272 +119,22 @@ export const getAlertColumnHeader = (timelineId: string, fieldId: string) =>
? defaultHeaders.find((c) => c.id === fieldId) ?? {}
: {};
export const CATEGORIES_PANE_CLASS_NAME = 'categories-pane';
export const CATEGORY_TABLE_CLASS_NAME = 'category-table';
export const CLOSE_BUTTON_CLASS_NAME = 'close-button';
export const RESET_FIELDS_CLASS_NAME = 'reset-fields';
export const VIEW_ALL_BUTTON_CLASS_NAME = 'view-all';
export const categoriesPaneHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${CATEGORIES_PANE_CLASS_NAME}`)
);
export const categoryTableHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${CATEGORY_TABLE_CLASS_NAME}`)
);
export const closeButtonHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${CLOSE_BUTTON_CLASS_NAME}`)
);
export const searchInputHasFocus = ({
containerElement,
timelineId,
}: {
containerElement: HTMLElement | null;
timelineId: string;
}): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(
`.${getFieldBrowserSearchInputClassName(timelineId)}`
)
);
export const viewAllHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${VIEW_ALL_BUTTON_CLASS_NAME}`)
);
export const resetButtonHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${RESET_FIELDS_CLASS_NAME}`)
);
export const scrollCategoriesPane = ({
containerElement,
selectedCategoryId,
timelineId,
}: {
containerElement: HTMLElement | null;
selectedCategoryId: string;
timelineId: string;
}) => {
if (selectedCategoryId !== '') {
const selectedCategories =
containerElement?.getElementsByClassName(
getCategoryPaneCategoryClassName({
categoryId: selectedCategoryId,
timelineId,
})
) ?? [];
if (selectedCategories.length > 0) {
selectedCategories[0].scrollIntoView();
}
}
};
export const focusCategoriesPane = ({
containerElement,
selectedCategoryId,
timelineId,
}: {
containerElement: HTMLElement | null;
selectedCategoryId: string;
timelineId: string;
}) => {
if (selectedCategoryId !== '') {
const selectedCategories =
containerElement?.getElementsByClassName(
getCategoryPaneCategoryClassName({
categoryId: selectedCategoryId,
timelineId,
})
) ?? [];
if (selectedCategories.length > 0) {
(selectedCategories[0] as HTMLButtonElement).focus();
}
}
};
export const focusCategoryTable = (containerElement: HTMLElement | null) => {
const firstEntry = containerElement?.querySelector<HTMLDivElement>(
`.${CATEGORY_TABLE_CLASS_NAME} [data-colindex="1"]`
);
if (firstEntry != null) {
firstEntry.focus();
} else {
skipFocusInContainerTo({
containerElement,
className: CATEGORY_TABLE_CLASS_NAME,
});
}
};
export const focusCloseButton = (containerElement: HTMLElement | null) =>
skipFocusInContainerTo({
containerElement,
className: CLOSE_BUTTON_CLASS_NAME,
});
export const focusResetFieldsButton = (containerElement: HTMLElement | null) =>
skipFocusInContainerTo({ containerElement, className: RESET_FIELDS_CLASS_NAME });
export const focusSearchInput = ({
containerElement,
timelineId,
}: {
containerElement: HTMLElement | null;
timelineId: string;
}) =>
skipFocusInContainerTo({
containerElement,
className: getFieldBrowserSearchInputClassName(timelineId),
});
export const focusViewAllButton = (containerElement: HTMLElement | null) =>
skipFocusInContainerTo({ containerElement, className: VIEW_ALL_BUTTON_CLASS_NAME });
export const onCategoriesPaneFocusChanging = ({
containerElement,
shiftKey,
timelineId,
}: {
containerElement: HTMLElement | null;
shiftKey: boolean;
timelineId: string;
}) =>
shiftKey
? focusSearchInput({
containerElement,
timelineId,
})
: focusViewAllButton(containerElement);
export const onCategoryTableFocusChanging = ({
containerElement,
shiftKey,
}: {
containerElement: HTMLElement | null;
shiftKey: boolean;
}) => (shiftKey ? focusViewAllButton(containerElement) : focusResetFieldsButton(containerElement));
export const onCloseButtonFocusChanging = ({
containerElement,
shiftKey,
timelineId,
}: {
containerElement: HTMLElement | null;
shiftKey: boolean;
timelineId: string;
}) =>
shiftKey
? focusResetFieldsButton(containerElement)
: focusSearchInput({ containerElement, timelineId });
export const onSearchInputFocusChanging = ({
containerElement,
selectedCategoryId,
shiftKey,
timelineId,
}: {
containerElement: HTMLElement | null;
selectedCategoryId: string;
shiftKey: boolean;
timelineId: string;
}) =>
shiftKey
? focusCloseButton(containerElement)
: focusCategoriesPane({ containerElement, selectedCategoryId, timelineId });
export const onViewAllFocusChanging = ({
containerElement,
selectedCategoryId,
shiftKey,
timelineId,
}: {
containerElement: HTMLElement | null;
selectedCategoryId: string;
shiftKey: boolean;
timelineId: string;
}) =>
shiftKey
? focusCategoriesPane({ containerElement, selectedCategoryId, timelineId })
: focusCategoryTable(containerElement);
export const onResetButtonFocusChanging = ({
containerElement,
shiftKey,
}: {
containerElement: HTMLElement | null;
shiftKey: boolean;
}) => (shiftKey ? focusCategoryTable(containerElement) : focusCloseButton(containerElement));
export const onFieldsBrowserTabPressed = ({
containerElement,
keyboardEvent,
selectedCategoryId,
timelineId,
}: {
containerElement: HTMLElement | null;
keyboardEvent: React.KeyboardEvent;
selectedCategoryId: string;
timelineId: string;
}) => {
const { shiftKey } = keyboardEvent;
if (searchInputHasFocus({ containerElement, timelineId })) {
stopPropagationAndPreventDefault(keyboardEvent);
onSearchInputFocusChanging({
containerElement,
selectedCategoryId,
shiftKey,
timelineId,
});
} else if (categoriesPaneHasFocus(containerElement)) {
stopPropagationAndPreventDefault(keyboardEvent);
onCategoriesPaneFocusChanging({
containerElement,
shiftKey,
timelineId,
});
} else if (viewAllHasFocus(containerElement)) {
stopPropagationAndPreventDefault(keyboardEvent);
onViewAllFocusChanging({
containerElement,
selectedCategoryId,
shiftKey,
timelineId,
});
} else if (categoryTableHasFocus(containerElement)) {
stopPropagationAndPreventDefault(keyboardEvent);
onCategoryTableFocusChanging({
containerElement,
shiftKey,
});
} else if (resetButtonHasFocus(containerElement)) {
stopPropagationAndPreventDefault(keyboardEvent);
onResetButtonFocusChanging({
containerElement,
shiftKey,
});
} else if (closeButtonHasFocus(containerElement)) {
stopPropagationAndPreventDefault(keyboardEvent);
onCloseButtonFocusChanging({
containerElement,
shiftKey,
timelineId,
});
}
};
export const CountBadge = styled(EuiBadge)`
margin-left: 5px;
` as unknown as typeof EuiBadge;
CountBadge.displayName = 'CountBadge';
export const CategoryName = styled.span<{ bold: boolean }>`
font-weight: ${({ bold }) => (bold ? 'bold' : 'normal')};
`;
CategoryName.displayName = 'CategoryName';
export const CategorySelectableContainer = styled.div`
width: 300px;
`;
CategorySelectableContainer.displayName = 'CategorySelectableContainer';

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import { mount } from 'enzyme';
import React from 'react';
import { waitFor } from '@testing-library/react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
@ -18,12 +17,8 @@ import { StatefulFieldsBrowserComponent } from '.';
describe('StatefulFieldsBrowser', () => {
const timelineId = 'test';
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = jest.fn();
});
test('it renders the Fields button, which displays the fields browser on click', () => {
const wrapper = mount(
it('should render the Fields button, which displays the fields browser on click', () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
@ -33,12 +28,12 @@ describe('StatefulFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="show-field-browser"]').exists()).toBe(true);
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
describe('toggleShow', () => {
test('it does NOT render the fields browser until the Fields button is clicked', () => {
const wrapper = mount(
it('should NOT render the fields browser until the Fields button is clicked', () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
@ -48,11 +43,11 @@ describe('StatefulFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(false);
expect(result.queryByTestId('fields-browser-container')).toBeNull();
});
test('it renders the fields browser when the Fields button is clicked', () => {
const wrapper = mount(
it('should render the fields browser when the Fields button is clicked', async () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
@ -61,88 +56,101 @@ describe('StatefulFieldsBrowser', () => {
/>
</TestProviders>
);
wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click');
expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(true);
});
});
describe('updateSelectedCategoryId', () => {
beforeEach(() => {
jest.useFakeTimers();
});
test('it updates the selectedCategoryId state, which makes the category bold, when the user clicks a category name in the left hand side of the field browser', async () => {
const wrapper = mount(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click');
wrapper.find(`.field-browser-category-pane-auditd-${timelineId}`).first().simulate('click');
result.getByTestId('show-field-browser').click();
await waitFor(() => {
wrapper.update();
expect(
wrapper
.find(`.field-browser-category-pane-auditd-${timelineId}`)
.find('[data-test-subj="categoryName"]')
.at(1)
).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' });
});
});
test('it updates the selectedCategoryId state according to most fields returned', async () => {
const wrapper = mount(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
await waitFor(() => {
wrapper.find('[data-test-subj="show-field-browser"]').first().simulate('click');
jest.runOnlyPendingTimers();
wrapper.update();
expect(
wrapper
.find(`.field-browser-category-pane-cloud-${timelineId}`)
.find('[data-test-subj="categoryName"]')
.at(1)
).toHaveStyleRule('font-weight', 'normal', { modifier: '.euiText' });
});
await waitFor(() => {
wrapper
.find('[data-test-subj="field-search"]')
.last()
.simulate('change', { target: { value: 'cloud' } });
jest.runOnlyPendingTimers();
wrapper.update();
expect(
wrapper
.find(`.field-browser-category-pane-cloud-${timelineId}`)
.find('[data-test-subj="categoryName"]')
.at(1)
).toHaveStyleRule('font-weight', 'bold', { modifier: '.euiText' });
expect(result.getByTestId('fields-browser-container')).toBeInTheDocument();
});
});
});
test('it renders the Fields Browser button as a settings gear when the isEventViewer prop is true', () => {
describe('updateSelectedCategoryIds', () => {
it('should add a selected category, which creates the category badge', async () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
result.getByTestId('show-field-browser').click();
await waitFor(() => {
expect(result.getByTestId('fields-browser-container')).toBeInTheDocument();
});
await act(async () => {
result.getByTestId('categories-filter-button').click();
});
await act(async () => {
result.getByTestId('categories-selector-option-base').click();
});
expect(result.getByTestId('category-badge-base')).toBeInTheDocument();
});
it('should remove a selected category, which deletes the category badge', async () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
result.getByTestId('show-field-browser').click();
await waitFor(() => {
expect(result.getByTestId('fields-browser-container')).toBeInTheDocument();
});
await act(async () => {
result.getByTestId('categories-filter-button').click();
});
await act(async () => {
result.getByTestId('categories-selector-option-base').click();
});
expect(result.getByTestId('category-badge-base')).toBeInTheDocument();
await act(async () => {
result.getByTestId('category-badge-unselect-base').click();
});
expect(result.queryByTestId('category-badge-base')).toBeNull();
});
it('should update the available categories according to the search input', async () => {
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
columnHeaders={[]}
timelineId={timelineId}
/>
</TestProviders>
);
result.getByTestId('show-field-browser').click();
await waitFor(() => {
expect(result.getByTestId('fields-browser-container')).toBeInTheDocument();
});
result.getByTestId('categories-filter-button').click();
expect(result.getByTestId('categories-selector-option-base')).toBeInTheDocument();
fireEvent.change(result.getByTestId('field-search'), { target: { value: 'client' } });
await waitFor(() => {
expect(result.queryByTestId('categories-selector-option-base')).toBeNull();
});
expect(result.queryByTestId('categories-selector-option-client')).toBeInTheDocument();
});
});
it('should render the Fields Browser button as a settings gear when the isEventViewer prop is true', () => {
const isEventViewer = true;
const wrapper = mount(
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
@ -153,13 +161,13 @@ describe('StatefulFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="show-field-browser"]').first().exists()).toBe(true);
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
test('it renders the Fields Browser button as a settings gear when the isEventViewer prop is false', () => {
const isEventViewer = true;
it('should render the Fields Browser button as a settings gear when the isEventViewer prop is false', () => {
const isEventViewer = false;
const wrapper = mount(
const result = render(
<TestProviders>
<StatefulFieldsBrowserComponent
browserFields={mockBrowserFields}
@ -171,6 +179,6 @@ describe('StatefulFieldsBrowser', () => {
</TestProviders>
);
expect(wrapper.find('[data-test-subj="show-field-browser"]').first().exists()).toBe(true);
expect(result.getByTestId('show-field-browser')).toBeInTheDocument();
});
});

View file

@ -6,15 +6,15 @@
*/
import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { debounce } from 'lodash';
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import styled from 'styled-components';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import { DEFAULT_CATEGORY_NAME } from '../../body/column_headers/default_headers';
import type { FieldBrowserProps } from '../../../../../common/types/fields_browser';
import { FieldsBrowser } from './field_browser';
import { filterBrowserFieldsByFieldName, mergeBrowserFieldsWithDefaultCategory } from './helpers';
import * as i18n from './translations';
import type { FieldBrowserProps } from './types';
const FIELDS_BUTTON_CLASS_NAME = 'fields-button';
@ -34,26 +34,48 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
timelineId,
columnHeaders,
browserFields,
createFieldComponent,
options,
width,
}) => {
const customizeColumnsButtonRef = useRef<HTMLButtonElement | null>(null);
/** tracks the latest timeout id from `setTimeout`*/
const inputTimeoutId = useRef(0);
/** all field names shown in the field browser must contain this string (when specified) */
const [filterInput, setFilterInput] = useState('');
/** debounced filterInput, the one that is applied to the filteredBrowserFields */
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 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 */
const [selectedCategoryId, setSelectedCategoryId] = useState(DEFAULT_CATEGORY_NAME);
const [selectedCategoryIds, setSelectedCategoryIds] = useState<string[]>([]);
/** show the field browser */
const [show, setShow] = useState(false);
// debounced function to apply the input filter
// will delay the call to setAppliedFilterInput by INPUT_TIMEOUT ms
// the parameter used will be the last one passed
const debouncedApplyFilterInput = useMemo(
() =>
debounce((input: string) => {
setAppliedFilterInput(input);
}, INPUT_TIMEOUT),
[]
);
useEffect(() => {
return () => {
debouncedApplyFilterInput.cancel();
};
}, [debouncedApplyFilterInput]);
useEffect(() => {
const newFilteredBrowserFields = filterBrowserFieldsByFieldName({
browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields),
substring: appliedFilterInput,
});
setFilteredBrowserFields(newFilteredBrowserFields);
setIsSearching(false);
}, [appliedFilterInput, browserFields]);
/** Shows / hides the field browser */
const onShow = useCallback(() => {
setShow(true);
@ -65,65 +87,19 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
setAppliedFilterInput('');
setFilteredBrowserFields(null);
setIsSearching(false);
setSelectedCategoryId(DEFAULT_CATEGORY_NAME);
setSelectedCategoryIds([]);
setShow(false);
}, []);
const newFilteredBrowserFields = useMemo(() => {
return filterBrowserFieldsByFieldName({
browserFields: mergeBrowserFieldsWithDefaultCategory(browserFields),
substring: appliedFilterInput,
});
}, [appliedFilterInput, browserFields]);
const newSelectedCategoryId = useMemo(() => {
if (appliedFilterInput === '' || Object.keys(newFilteredBrowserFields).length === 0) {
return DEFAULT_CATEGORY_NAME;
} else {
return Object.keys(newFilteredBrowserFields)
.sort()
.reduce<string>((selected, category) => {
const filteredBrowserFieldsByCategory =
(newFilteredBrowserFields[category] && newFilteredBrowserFields[category].fields) || [];
const filteredBrowserFieldsBySelected =
(newFilteredBrowserFields[selected] && newFilteredBrowserFields[selected].fields) || [];
return newFilteredBrowserFields[category].fields != null &&
newFilteredBrowserFields[selected].fields != null &&
Object.keys(filteredBrowserFieldsByCategory).length >
Object.keys(filteredBrowserFieldsBySelected).length
? category
: selected;
}, Object.keys(newFilteredBrowserFields)[0]);
}
}, [appliedFilterInput, newFilteredBrowserFields]);
/** Invoked when the user types in the filter input */
const updateFilter = useCallback((newFilterInput: string) => {
setFilterInput(newFilterInput);
setIsSearching(true);
}, []);
useEffect(() => {
if (inputTimeoutId.current !== 0) {
clearTimeout(inputTimeoutId.current); // ⚠️ mutation: cancel any previous timers
}
// ⚠️ mutation: schedule a new timer that will apply the filter when it fires:
inputTimeoutId.current = window.setTimeout(() => {
setIsSearching(false);
setAppliedFilterInput(filterInput);
}, INPUT_TIMEOUT);
return () => {
clearTimeout(inputTimeoutId.current);
};
}, [filterInput]);
useEffect(() => {
setFilteredBrowserFields(newFilteredBrowserFields);
}, [newFilteredBrowserFields]);
useEffect(() => {
setSelectedCategoryId(newSelectedCategoryId);
}, [newSelectedCategoryId]);
const updateFilter = useCallback(
(newFilterInput: string) => {
setIsSearching(true);
setFilterInput(newFilterInput);
debouncedApplyFilterInput(newFilterInput);
},
[debouncedApplyFilterInput]
);
// only merge in the default category if the field browser is visible
const browserFieldsWithDefaultCategory = useMemo(() => {
@ -150,19 +126,19 @@ export const StatefulFieldsBrowserComponent: React.FC<FieldBrowserProps> = ({
{show && (
<FieldsBrowser
browserFields={browserFieldsWithDefaultCategory}
createFieldComponent={createFieldComponent}
columnHeaders={columnHeaders}
filteredBrowserFields={
filteredBrowserFields != null ? filteredBrowserFields : browserFieldsWithDefaultCategory
}
isSearching={isSearching}
onCategorySelected={setSelectedCategoryId}
setSelectedCategoryIds={setSelectedCategoryIds}
onHide={onHide}
onSearchInputChange={updateFilter}
options={options}
restoreFocusTo={customizeColumnsButtonRef}
searchInput={filterInput}
appliedFilterInput={appliedFilterInput}
selectedCategoryId={selectedCategoryId}
selectedCategoryIds={selectedCategoryIds}
timelineId={timelineId}
width={width}
/>

View file

@ -7,7 +7,7 @@
import { mount } from 'enzyme';
import React from 'react';
import { mockBrowserFields, TestProviders } from '../../../../mock';
import { TestProviders } from '../../../../mock';
import { Search } from './search';
const timelineId = 'test';
@ -17,7 +17,6 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
@ -37,7 +36,6 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput={searchInput}
@ -53,7 +51,6 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={true}
onSearchInputChange={jest.fn()}
searchInput=""
@ -71,7 +68,6 @@ describe('Search', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={false}
onSearchInputChange={onSearchInputChange}
searchInput=""
@ -88,72 +84,4 @@ describe('Search', () => {
expect(onSearchInputChange).toBeCalled();
});
test('it returns the expected categories count when filteredBrowserFields is empty', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={{}}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="categories-count"]').first().text()).toEqual(
'0 categories'
);
});
test('it returns the expected categories count when filteredBrowserFields is NOT empty', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="categories-count"]').first().text()).toEqual(
'12 categories'
);
});
test('it returns the expected fields count when filteredBrowserFields is empty', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={{}}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('0 fields');
});
test('it returns the expected fields count when filteredBrowserFields is NOT empty', () => {
const wrapper = mount(
<TestProviders>
<Search
filteredBrowserFields={mockBrowserFields}
isSearching={false}
onSearchInputChange={jest.fn()}
searchInput=""
timelineId={timelineId}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="fields-count"]').first().text()).toEqual('34 fields');
});
});

View file

@ -6,75 +6,28 @@
*/
import React from 'react';
import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import styled from 'styled-components';
import type { BrowserFields } from '../../../../../common/search_strategy';
import { getFieldBrowserSearchInputClassName, getFieldCount } from './helpers';
import { EuiFieldSearch } from '@elastic/eui';
import * as i18n from './translations';
const CountsFlexGroup = styled(EuiFlexGroup)`
margin-top: ${({ theme }) => theme.eui.euiSizeXS};
margin-left: ${({ theme }) => theme.eui.euiSizeXS};
`;
CountsFlexGroup.displayName = 'CountsFlexGroup';
interface Props {
filteredBrowserFields: BrowserFields;
isSearching: boolean;
onSearchInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
searchInput: string;
timelineId: string;
}
const CountRow = React.memo<Pick<Props, 'filteredBrowserFields'>>(({ filteredBrowserFields }) => (
<CountsFlexGroup
alignItems="center"
data-test-subj="counts-flex-group"
direction="row"
gutterSize="xs"
>
<EuiFlexItem grow={false}>
<EuiText color="subdued" data-test-subj="categories-count" size="xs">
{i18n.CATEGORIES_COUNT(Object.keys(filteredBrowserFields).length)}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued" data-test-subj="fields-count" size="xs">
{i18n.FIELDS_COUNT(
Object.keys(filteredBrowserFields).reduce<number>(
(fieldsCount, category) => getFieldCount(filteredBrowserFields[category]) + fieldsCount,
0
)
)}
</EuiText>
</EuiFlexItem>
</CountsFlexGroup>
));
CountRow.displayName = 'CountRow';
const inputRef = (node: HTMLInputElement | null) => node?.focus();
export const Search = React.memo<Props>(
({ isSearching, filteredBrowserFields, onSearchInputChange, searchInput, timelineId }) => (
<>
<EuiFieldSearch
className={getFieldBrowserSearchInputClassName(timelineId)}
data-test-subj="field-search"
inputRef={inputRef}
isLoading={isSearching}
onChange={onSearchInputChange}
placeholder={i18n.FILTER_PLACEHOLDER}
value={searchInput}
fullWidth
/>
<CountRow filteredBrowserFields={filteredBrowserFields} />
</>
({ isSearching, onSearchInputChange, searchInput, timelineId }) => (
<EuiFieldSearch
data-test-subj="field-search"
inputRef={inputRef}
isLoading={isSearching}
onChange={onSearchInputChange}
placeholder={i18n.FILTER_PLACEHOLDER}
value={searchInput}
fullWidth
/>
)
);
Search.displayName = 'Search';

View file

@ -21,21 +21,6 @@ export const CATEGORIES_COUNT = (totalCount: number) =>
defaultMessage: '{totalCount} {totalCount, plural, =1 {category} other {categories}}',
});
export const CATEGORY_LINK = ({ category, totalCount }: { category: string; totalCount: number }) =>
i18n.translate('xpack.timelines.fieldBrowser.categoryLinkAriaLabel', {
values: { category, totalCount },
defaultMessage:
'{category} {totalCount} {totalCount, plural, =1 {field} other {fields}}. Click this button to select the {category} category.',
});
export const CATEGORY_FIELDS_TABLE_CAPTION = (categoryId: string) =>
i18n.translate('xpack.timelines.fieldBrowser.categoryFieldsTableCaption', {
defaultMessage: 'category {categoryId} fields',
values: {
categoryId,
},
});
export const CLOSE = i18n.translate('xpack.timelines.fieldBrowser.closeButton', {
defaultMessage: 'Close',
});
@ -56,6 +41,10 @@ export const DESCRIPTION_FOR_FIELD = (field: string) =>
defaultMessage: 'Description for field {field}:',
});
export const NAME = i18n.translate('xpack.timelines.fieldBrowser.fieldName', {
defaultMessage: 'Name',
});
export const FIELD = i18n.translate('xpack.timelines.fieldBrowser.fieldLabel', {
defaultMessage: 'Field',
});
@ -64,10 +53,14 @@ export const FIELDS = i18n.translate('xpack.timelines.fieldBrowser.fieldsTitle',
defaultMessage: 'Fields',
});
export const FIELDS_SHOWING = i18n.translate('xpack.timelines.fieldBrowser.fieldsCountShowing', {
defaultMessage: 'Showing',
});
export const FIELDS_COUNT = (totalCount: number) =>
i18n.translate('xpack.timelines.fieldBrowser.fieldsCountTitle', {
values: { totalCount },
defaultMessage: '{totalCount} {totalCount, plural, =1 {field} other {fields}}',
defaultMessage: '{totalCount, plural, =1 {field} other {fields}}',
});
export const FILTER_PLACEHOLDER = i18n.translate('xpack.timelines.fieldBrowser.filterPlaceholder', {
@ -90,14 +83,6 @@ export const RESET_FIELDS = i18n.translate('xpack.timelines.fieldBrowser.resetFi
defaultMessage: 'Reset Fields',
});
export const VIEW_ALL_CATEGORY_FIELDS = (categoryId: string) =>
i18n.translate('xpack.timelines.fieldBrowser.viewCategoryTooltip', {
defaultMessage: 'View all {categoryId} fields',
values: {
categoryId,
},
});
export const VIEW_COLUMN = (field: string) =>
i18n.translate('xpack.timelines.fieldBrowser.viewColumnCheckboxAriaLabel', {
values: { field },

View file

@ -1,27 +0,0 @@
/*
* 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 { CreateFieldComponentType } from '../../../../../common/types';
import type { BrowserFields } from '../../../../../common/search_strategy/index_fields';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline/columns';
export type OnFieldSelected = (fieldId: string) => void;
export interface FieldBrowserProps {
/** The timeline associated with this field browser */
timelineId: string;
/** The timeline's current column headers */
columnHeaders: ColumnHeaderOptions[];
/** A map of categoryId -> metadata about the fields in that category */
browserFields: BrowserFields;
createFieldComponent?: CreateFieldComponentType;
/** When true, this Fields Browser is being used as an "events viewer" */
isEventViewer?: boolean;
/** The width of the field browser */
width?: number;
}

View file

@ -27035,9 +27035,7 @@
"xpack.timelines.exitFullScreenButton": "全画面を終了",
"xpack.timelines.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, other {カテゴリ}}",
"xpack.timelines.fieldBrowser.categoriesTitle": "カテゴリー",
"xpack.timelines.fieldBrowser.categoryFieldsTableCaption": "カテゴリ {categoryId} フィールド",
"xpack.timelines.fieldBrowser.categoryLabel": "カテゴリー",
"xpack.timelines.fieldBrowser.categoryLinkAriaLabel": "{category} {totalCount} {totalCount, plural, other {フィールド}}このボタンをクリックすると、{category} カテゴリを選択します。",
"xpack.timelines.fieldBrowser.closeButton": "閉じる",
"xpack.timelines.fieldBrowser.descriptionForScreenReaderOnly": "フィールド {field} の説明:",
"xpack.timelines.fieldBrowser.descriptionLabel": "説明",
@ -27049,7 +27047,6 @@
"xpack.timelines.fieldBrowser.noFieldsMatchInputLabel": "{searchInput} に一致するフィールドがありません",
"xpack.timelines.fieldBrowser.noFieldsMatchLabel": "一致するフィールドがありません",
"xpack.timelines.fieldBrowser.resetFieldsLink": "フィールドをリセット",
"xpack.timelines.fieldBrowser.viewCategoryTooltip": "すべての {categoryId} フィールドを表示します",
"xpack.timelines.fieldBrowser.viewColumnCheckboxAriaLabel": "{field} 列を表示",
"xpack.timelines.footer.autoRefreshActiveDescription": "自動更新アクション",
"xpack.timelines.footer.autoRefreshActiveTooltip": "自動更新が有効な間、タイムラインはクエリに一致する最新の {numberOfItems} 件のイベントを表示します。",

View file

@ -27067,9 +27067,7 @@
"xpack.timelines.exitFullScreenButton": "退出全屏",
"xpack.timelines.fieldBrowser.categoriesCountTitle": "{totalCount} {totalCount, plural, other {个类别}}",
"xpack.timelines.fieldBrowser.categoriesTitle": "类别",
"xpack.timelines.fieldBrowser.categoryFieldsTableCaption": "类别 {categoryId} 字段",
"xpack.timelines.fieldBrowser.categoryLabel": "类别",
"xpack.timelines.fieldBrowser.categoryLinkAriaLabel": "{category} {totalCount} 个{totalCount, plural, other {字段}}。单击此按钮可选择 {category} 类别。",
"xpack.timelines.fieldBrowser.closeButton": "关闭",
"xpack.timelines.fieldBrowser.descriptionForScreenReaderOnly": "{field} 字段的描述:",
"xpack.timelines.fieldBrowser.descriptionLabel": "描述",
@ -27081,7 +27079,6 @@
"xpack.timelines.fieldBrowser.noFieldsMatchInputLabel": "没有字段匹配“{searchInput}”",
"xpack.timelines.fieldBrowser.noFieldsMatchLabel": "没有字段匹配",
"xpack.timelines.fieldBrowser.resetFieldsLink": "重置字段",
"xpack.timelines.fieldBrowser.viewCategoryTooltip": "查看所有 {categoryId} 字段",
"xpack.timelines.fieldBrowser.viewColumnCheckboxAriaLabel": "查看 {field} 列",
"xpack.timelines.footer.autoRefreshActiveDescription": "自动刷新已启用",
"xpack.timelines.footer.autoRefreshActiveTooltip": "自动刷新已启用时,时间线将显示匹配查询的最近 {numberOfItems} 个事件。",