mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
90f0d8de01
commit
a79562a67e
63 changed files with 1701 additions and 2818 deletions
|
@ -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')
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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"]`;
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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}
|
|
@ -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';
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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',
|
||||
},
|
||||
];
|
|
@ -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',
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: () => <></>,
|
||||
}));
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>>>;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -5,4 +5,5 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './fields_browser';
|
||||
export * from './timeline';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>>;
|
||||
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
|
@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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} 件のイベントを表示します。",
|
||||
|
|
|
@ -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} 个事件。",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue