mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Discover][UnifiedFieldList] Integrate unified field list sections into Discover (#144412)
Closes https://github.com/elastic/kibana/issues/135678 ## Summary This PR continues the work started in https://github.com/elastic/kibana/pull/142758 to bring field list grouping from Lens into Discover. - [x] Integrate new components and hooks into Discover page - [x] Refactor fields grouping logic - [x] Render Popular fields under a new separate section - [x] Remove "Hide empty fields" switch - [x] Adjust filtering logic - [x] Refactor fields existence logic in Discover - [x] Add "Unmapped fields" section - [x] Highlight the matching term when searching for a field - [x] Show field icons when in SQL mode - [x] Add tooltips to field list section headings - [x] Add tests, clean up <img width="340" alt="Screenshot 2022-11-15 at 15 39 27" src="https://user-images.githubusercontent.com/1415710/201947349-726ffc3a-a17f-411b-be92-81d97879765a.png"> For testing on Discover page: Please check different use cases and toggling Advanced Settings: - regular vs ad-hoc data views - data views with and without a time field - data views with unmapped and empty fields - data views with a lot of fields - data views with some fields being filtered out via data view configuration - updating query, filters, and time range - regular and SQL mode - searching by a field name in the sidebar - applying a field filter in the sidebar - adding, editing, and removing a field - Field Statistics table when some columns are selected or no columns are selected - multifields in the field popover should work as before (icon should change from "+" to "x" when subfield is selected as a column) - `discover:searchOnPageLoad` should not show fields if turned off - `discover:searchFieldsFromSource` should show multifields right in the fields list if enabled - `discover:enableSql` should show Selected and Available fields only when enabled - `discover:showLegacyFieldTopValues` should show old (green) field stats in its popover - `doc_table:legacy` On Lens page: - scroll position should reset when data view is switched or when searching by a field name - regular and SQL mode ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) Co-authored-by: Michael Marcialis <michael@marcial.is> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
parent
08805d0d69
commit
66718fc2c1
72 changed files with 3203 additions and 1681 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -12,6 +12,7 @@
|
|||
/src/plugins/discover/ @elastic/kibana-data-discovery
|
||||
/src/plugins/saved_search/ @elastic/kibana-data-discovery
|
||||
/x-pack/plugins/discover_enhanced/ @elastic/kibana-data-discovery
|
||||
/x-pack/test/functional/apps/discover/ @elastic/kibana-data-discovery
|
||||
/test/functional/apps/discover/ @elastic/kibana-data-discovery
|
||||
/test/functional/apps/context/ @elastic/kibana-data-discovery
|
||||
/test/api_integration/apis/unified_field_list/ @elastic/kibana-data-discovery
|
||||
|
|
|
@ -11,22 +11,27 @@ import { dataViewMock } from './data_view';
|
|||
import { dataViewComplexMock } from './data_view_complex';
|
||||
import { dataViewWithTimefieldMock } from './data_view_with_timefield';
|
||||
|
||||
export const dataViewsMock = {
|
||||
getCache: async () => {
|
||||
return [dataViewMock];
|
||||
},
|
||||
get: async (id: string) => {
|
||||
if (id === 'the-data-view-id') {
|
||||
return Promise.resolve(dataViewMock);
|
||||
} else if (id === 'invalid-data-view-id') {
|
||||
return Promise.reject('Invald');
|
||||
}
|
||||
},
|
||||
updateSavedObject: jest.fn(),
|
||||
getIdsWithTitle: jest.fn(() => {
|
||||
return Promise.resolve([dataViewMock, dataViewComplexMock, dataViewWithTimefieldMock]);
|
||||
}),
|
||||
createFilter: jest.fn(),
|
||||
create: jest.fn(),
|
||||
clearInstanceCache: jest.fn(),
|
||||
} as unknown as jest.Mocked<DataViewsContract>;
|
||||
export function createDiscoverDataViewsMock() {
|
||||
return {
|
||||
getCache: async () => {
|
||||
return [dataViewMock];
|
||||
},
|
||||
get: async (id: string) => {
|
||||
if (id === 'the-data-view-id') {
|
||||
return Promise.resolve(dataViewMock);
|
||||
} else if (id === 'invalid-data-view-id') {
|
||||
return Promise.reject('Invald');
|
||||
}
|
||||
},
|
||||
updateSavedObject: jest.fn(),
|
||||
getIdsWithTitle: jest.fn(() => {
|
||||
return Promise.resolve([dataViewMock, dataViewComplexMock, dataViewWithTimefieldMock]);
|
||||
}),
|
||||
createFilter: jest.fn(),
|
||||
create: jest.fn(),
|
||||
clearInstanceCache: jest.fn(),
|
||||
getFieldsForIndexPattern: jest.fn((dataView) => dataView.fields),
|
||||
} as unknown as jest.Mocked<DataViewsContract>;
|
||||
}
|
||||
|
||||
export const dataViewsMock = createDiscoverDataViewsMock();
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
|
||||
import { DiscoverServices } from '../build_services';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
|
@ -20,117 +21,137 @@ import {
|
|||
SORT_DEFAULT_ORDER_SETTING,
|
||||
HIDE_ANNOUNCEMENTS,
|
||||
} from '../../common';
|
||||
import { UI_SETTINGS } from '@kbn/data-plugin/public';
|
||||
import { UI_SETTINGS, calculateBounds } from '@kbn/data-plugin/public';
|
||||
import { TopNavMenu } from '@kbn/navigation-plugin/public';
|
||||
import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common';
|
||||
import { LocalStorageMock } from './local_storage_mock';
|
||||
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
||||
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
|
||||
import { dataViewsMock } from './data_views';
|
||||
import { Observable, of } from 'rxjs';
|
||||
const dataPlugin = dataPluginMock.createStartContract();
|
||||
const expressionsPlugin = expressionsPluginMock.createStartContract();
|
||||
import { LocalStorageMock } from './local_storage_mock';
|
||||
import { createDiscoverDataViewsMock } from './data_views';
|
||||
|
||||
dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
|
||||
dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable<void>);
|
||||
export function createDiscoverServicesMock(): DiscoverServices {
|
||||
const dataPlugin = dataPluginMock.createStartContract();
|
||||
const expressionsPlugin = expressionsPluginMock.createStartContract();
|
||||
|
||||
export const discoverServiceMock = {
|
||||
core: coreMock.createStart(),
|
||||
chrome: chromeServiceMock.createStartContract(),
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
listen: jest.fn(),
|
||||
}),
|
||||
data: dataPlugin,
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
},
|
||||
discover: {
|
||||
save: false,
|
||||
},
|
||||
advancedSettings: {
|
||||
save: true,
|
||||
},
|
||||
},
|
||||
fieldFormats: fieldFormatsMock,
|
||||
filterManager: dataPlugin.query.filterManager,
|
||||
inspector: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
} else if (key === DEFAULT_COLUMNS_SETTING) {
|
||||
return ['default_column'];
|
||||
} else if (key === UI_SETTINGS.META_FIELDS) {
|
||||
return [];
|
||||
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
|
||||
return false;
|
||||
} else if (key === CONTEXT_STEP_SETTING) {
|
||||
return 5;
|
||||
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
|
||||
return 'desc';
|
||||
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
|
||||
return false;
|
||||
} else if (key === SAMPLE_SIZE_SETTING) {
|
||||
return 250;
|
||||
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
|
||||
return 150;
|
||||
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
|
||||
return 50;
|
||||
} else if (key === HIDE_ANNOUNCEMENTS) {
|
||||
return false;
|
||||
}
|
||||
dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
|
||||
dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable<void>);
|
||||
dataPlugin.query.timefilter.timefilter.createFilter = jest.fn();
|
||||
dataPlugin.query.timefilter.timefilter.getAbsoluteTime = jest.fn(() => ({
|
||||
from: '2021-08-31T22:00:00.000Z',
|
||||
to: '2022-09-01T09:16:29.553Z',
|
||||
}));
|
||||
dataPlugin.query.timefilter.timefilter.getTime = jest.fn(() => {
|
||||
return { from: 'now-15m', to: 'now' };
|
||||
});
|
||||
dataPlugin.query.timefilter.timefilter.calculateBounds = jest.fn(calculateBounds);
|
||||
dataPlugin.query.getState = jest.fn(() => ({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
}));
|
||||
dataPlugin.dataViews = createDiscoverDataViewsMock();
|
||||
|
||||
return {
|
||||
core: coreMock.createStart(),
|
||||
charts: chartPluginMock.createSetupContract(),
|
||||
chrome: chromeServiceMock.createStartContract(),
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
listen: jest.fn(),
|
||||
}),
|
||||
isDefault: (key: string) => {
|
||||
return true;
|
||||
data: dataPlugin,
|
||||
docLinks: docLinksServiceMock.createStartContract(),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
},
|
||||
discover: {
|
||||
save: false,
|
||||
},
|
||||
advancedSettings: {
|
||||
save: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
http: {
|
||||
basePath: '/',
|
||||
},
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: () => true,
|
||||
fieldFormats: fieldFormatsMock,
|
||||
filterManager: dataPlugin.query.filterManager,
|
||||
inspector: {
|
||||
open: jest.fn(),
|
||||
},
|
||||
},
|
||||
dataViewFieldEditor: {
|
||||
openEditor: jest.fn(),
|
||||
userPermissions: {
|
||||
editIndexPattern: jest.fn(),
|
||||
uiSettings: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
} else if (key === DEFAULT_COLUMNS_SETTING) {
|
||||
return ['default_column'];
|
||||
} else if (key === UI_SETTINGS.META_FIELDS) {
|
||||
return [];
|
||||
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
|
||||
return false;
|
||||
} else if (key === CONTEXT_STEP_SETTING) {
|
||||
return 5;
|
||||
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
|
||||
return 'desc';
|
||||
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
|
||||
return false;
|
||||
} else if (key === SAMPLE_SIZE_SETTING) {
|
||||
return 250;
|
||||
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
|
||||
return 150;
|
||||
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
|
||||
return 50;
|
||||
} else if (key === HIDE_ANNOUNCEMENTS) {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
isDefault: (key: string) => {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
ui: { TopNavMenu, AggregateQueryTopNavMenu: TopNavMenu },
|
||||
},
|
||||
metadata: {
|
||||
branch: 'test',
|
||||
},
|
||||
theme: {
|
||||
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
},
|
||||
storage: new LocalStorageMock({}) as unknown as Storage,
|
||||
addBasePath: jest.fn(),
|
||||
toastNotifications: {
|
||||
addInfo: jest.fn(),
|
||||
addWarning: jest.fn(),
|
||||
addDanger: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
expressions: expressionsPlugin,
|
||||
savedObjectsTagging: {},
|
||||
dataViews: dataViewsMock,
|
||||
timefilter: { createFilter: jest.fn() },
|
||||
locator: {
|
||||
useUrl: jest.fn(() => ''),
|
||||
navigate: jest.fn(),
|
||||
getUrl: jest.fn(() => Promise.resolve('')),
|
||||
},
|
||||
contextLocator: { getRedirectUrl: jest.fn(() => '') },
|
||||
singleDocLocator: { getRedirectUrl: jest.fn(() => '') },
|
||||
} as unknown as DiscoverServices;
|
||||
http: {
|
||||
basePath: '/',
|
||||
},
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: () => true,
|
||||
},
|
||||
},
|
||||
dataViewFieldEditor: {
|
||||
openEditor: jest.fn(),
|
||||
userPermissions: {
|
||||
editIndexPattern: jest.fn(),
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
ui: { TopNavMenu, AggregateQueryTopNavMenu: TopNavMenu },
|
||||
},
|
||||
metadata: {
|
||||
branch: 'test',
|
||||
},
|
||||
theme: {
|
||||
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
|
||||
},
|
||||
storage: new LocalStorageMock({}) as unknown as Storage,
|
||||
addBasePath: jest.fn(),
|
||||
toastNotifications: {
|
||||
addInfo: jest.fn(),
|
||||
addWarning: jest.fn(),
|
||||
addDanger: jest.fn(),
|
||||
addSuccess: jest.fn(),
|
||||
},
|
||||
expressions: expressionsPlugin,
|
||||
savedObjectsTagging: {},
|
||||
dataViews: dataPlugin.dataViews,
|
||||
timefilter: dataPlugin.query.timefilter.timefilter,
|
||||
locator: {
|
||||
useUrl: jest.fn(() => ''),
|
||||
navigate: jest.fn(),
|
||||
getUrl: jest.fn(() => Promise.resolve('')),
|
||||
},
|
||||
contextLocator: { getRedirectUrl: jest.fn(() => '') },
|
||||
singleDocLocator: { getRedirectUrl: jest.fn(() => '') },
|
||||
} as unknown as DiscoverServices;
|
||||
}
|
||||
|
||||
export const discoverServiceMock = createDiscoverServicesMock();
|
||||
|
|
|
@ -31,6 +31,10 @@ discover-app {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dscPageBody__sidebar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dscPageContent__wrapper {
|
||||
padding: $euiSizeS $euiSizeS $euiSizeS 0;
|
||||
overflow: hidden; // Ensures horizontal scroll of table
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
DataTotalHits$,
|
||||
RecordRawType,
|
||||
} from '../../hooks/use_saved_search';
|
||||
import { discoverServiceMock } from '../../../../__mocks__/services';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { DiscoverSidebar } from '../sidebar/discover_sidebar';
|
||||
|
@ -118,14 +118,11 @@ async function mountComponent(
|
|||
) {
|
||||
const searchSourceMock = createSearchSourceMock({});
|
||||
const services = {
|
||||
...discoverServiceMock,
|
||||
...createDiscoverServicesMock(),
|
||||
storage: new LocalStorageMock({
|
||||
[SIDEBAR_CLOSED_KEY]: prevSidebarClosed,
|
||||
}) as unknown as Storage,
|
||||
} as unknown as DiscoverServices;
|
||||
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
|
||||
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
|
||||
};
|
||||
|
||||
const dataViewList = [dataView];
|
||||
|
||||
|
|
|
@ -245,7 +245,7 @@ export function DiscoverLayout({
|
|||
history={history}
|
||||
/>
|
||||
<EuiFlexGroup className="dscPageBody__contents" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexItem grow={false} className="dscPageBody__sidebar">
|
||||
<SidebarMemoized
|
||||
columns={columns}
|
||||
documents$={savedSearchData$.documents$}
|
||||
|
|
|
@ -21,7 +21,7 @@ import {
|
|||
DataTotalHits$,
|
||||
RecordRawType,
|
||||
} from '../../hooks/use_saved_search';
|
||||
import { discoverServiceMock } from '../../../../__mocks__/services';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { buildDataTableRecord } from '../../../../utils/build_data_record';
|
||||
|
@ -110,10 +110,7 @@ const mountComponent = async ({
|
|||
savedSearch?: SavedSearch;
|
||||
resetSavedSearch?: () => void;
|
||||
} = {}) => {
|
||||
let services = discoverServiceMock;
|
||||
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
|
||||
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
|
||||
};
|
||||
let services = createDiscoverServicesMock();
|
||||
|
||||
if (storage) {
|
||||
services = { ...services, storage };
|
||||
|
|
|
@ -1,94 +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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import React from 'react';
|
||||
import { KBN_FIELD_TYPES } from '@kbn/field-types';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { DiscoverFieldDetails } from '../discover_field_details';
|
||||
import { fieldSpecMap } from './fields';
|
||||
import { numericField as field } from './fields';
|
||||
import { Bucket } from '../types';
|
||||
|
||||
const buckets = [
|
||||
{ count: 1, display: 'Stewart', percent: 50.0, value: 'Stewart' },
|
||||
{ count: 1, display: 'Perry', percent: 50.0, value: 'Perry' },
|
||||
] as Bucket[];
|
||||
const details = { buckets, error: '', exists: 1, total: 2, columns: [] };
|
||||
|
||||
const fieldFormatInstanceType = {};
|
||||
const defaultMap = {
|
||||
[KBN_FIELD_TYPES.NUMBER]: { id: KBN_FIELD_TYPES.NUMBER, params: {} },
|
||||
};
|
||||
|
||||
const fieldFormat = {
|
||||
getByFieldType: (fieldType: KBN_FIELD_TYPES) => {
|
||||
return [fieldFormatInstanceType];
|
||||
},
|
||||
getDefaultConfig: () => {
|
||||
return defaultMap.number;
|
||||
},
|
||||
defaultMap,
|
||||
};
|
||||
|
||||
const scriptedField = new DataViewField({
|
||||
name: 'machine.os',
|
||||
type: 'string',
|
||||
esTypes: ['long'],
|
||||
count: 10,
|
||||
scripted: true,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
});
|
||||
|
||||
const dataView = new DataView({
|
||||
spec: {
|
||||
id: 'logstash-*',
|
||||
fields: fieldSpecMap,
|
||||
title: 'logstash-*',
|
||||
timeFieldName: '@timestamp',
|
||||
},
|
||||
metaFields: ['_id', '_type', '_source'],
|
||||
shortDotsEnable: false,
|
||||
// @ts-expect-error
|
||||
fieldFormats: fieldFormat,
|
||||
});
|
||||
|
||||
storiesOf('components/sidebar/DiscoverFieldDetails', module)
|
||||
.add('default', () => (
|
||||
<div style={{ width: '50%' }}>
|
||||
<DiscoverFieldDetails
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
details={details}
|
||||
onAddFilter={() => {
|
||||
alert('On add filter clicked');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
.add('scripted', () => (
|
||||
<div style={{ width: '50%' }}>
|
||||
<DiscoverFieldDetails
|
||||
field={scriptedField}
|
||||
dataView={dataView}
|
||||
details={details}
|
||||
onAddFilter={() => {}}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
.add('error', () => (
|
||||
<DiscoverFieldDetails
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
details={{ buckets: [], error: 'An error occurred', exists: 1, total: 2 }}
|
||||
onAddFilter={() => {}}
|
||||
/>
|
||||
));
|
|
@ -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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataViewField, FieldSpec } from '@kbn/data-views-plugin/public';
|
||||
|
||||
export const fieldSpecMap: Record<string, FieldSpec> = {
|
||||
'machine.os': {
|
||||
name: 'machine.os',
|
||||
esTypes: ['text'],
|
||||
type: 'string',
|
||||
aggregatable: false,
|
||||
searchable: false,
|
||||
},
|
||||
'machine.os.raw': {
|
||||
name: 'machine.os.raw',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
'not.filterable': {
|
||||
name: 'not.filterable',
|
||||
type: 'string',
|
||||
esTypes: ['text'],
|
||||
aggregatable: true,
|
||||
searchable: false,
|
||||
},
|
||||
bytes: {
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
esTypes: ['long'],
|
||||
aggregatable: true,
|
||||
searchable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const numericField = new DataViewField({
|
||||
name: 'bytes',
|
||||
type: 'number',
|
||||
esTypes: ['long'],
|
||||
count: 10,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
});
|
|
@ -12,7 +12,11 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
|
|||
|
||||
import { DiscoverFieldDetails } from './discover_field_details';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { stubDataView, stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { FetchStatus } from '../../../../types';
|
||||
import { DataDocuments$ } from '../../../hooks/use_saved_search';
|
||||
import { getDataTableRecords } from '../../../../../__fixtures__/real_hits';
|
||||
|
||||
describe('discover sidebar field details', function () {
|
||||
const onAddFilter = jest.fn();
|
||||
|
@ -21,9 +25,14 @@ describe('discover sidebar field details', function () {
|
|||
details: { buckets: [], error: '', exists: 1, total: 2, columns: [] },
|
||||
onAddFilter,
|
||||
};
|
||||
const hits = getDataTableRecords(stubLogstashDataView);
|
||||
const documents$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
result: hits,
|
||||
}) as DataDocuments$;
|
||||
|
||||
function mountComponent(field: DataViewField) {
|
||||
const compProps = { ...defaultProps, field };
|
||||
const compProps = { ...defaultProps, field, documents$ };
|
||||
return mountWithIntl(<DiscoverFieldDetails {...compProps} />);
|
||||
}
|
||||
|
|
@ -6,28 +6,52 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiText, EuiSpacer, EuiLink, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { DataViewField, DataView } from '@kbn/data-views-plugin/public';
|
||||
import { DiscoverFieldBucket } from './discover_field_bucket';
|
||||
import { Bucket, FieldDetails } from './types';
|
||||
import { getDetails } from './get_details';
|
||||
import { DataDocuments$ } from '../../../hooks/use_saved_search';
|
||||
import { FetchStatus } from '../../../../types';
|
||||
|
||||
interface DiscoverFieldDetailsProps {
|
||||
/**
|
||||
* hits fetched from ES, displayed in the doc table
|
||||
*/
|
||||
documents$: DataDocuments$;
|
||||
field: DataViewField;
|
||||
dataView: DataView;
|
||||
details: FieldDetails;
|
||||
onAddFilter?: (field: DataViewField | string, value: string, type: '+' | '-') => void;
|
||||
}
|
||||
|
||||
export function DiscoverFieldDetails({
|
||||
documents$,
|
||||
field,
|
||||
dataView,
|
||||
details,
|
||||
onAddFilter,
|
||||
}: DiscoverFieldDetailsProps) {
|
||||
const details: FieldDetails = useMemo(() => {
|
||||
const data = documents$.getValue();
|
||||
const documents = data.fetchStatus === FetchStatus.COMPLETE ? data.result : undefined;
|
||||
return getDetails(field, documents, dataView);
|
||||
}, [field, documents$, dataView]);
|
||||
|
||||
if (!details?.error && !details?.buckets) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-test-subj={`discoverFieldDetails-${field.name}`}>
|
||||
<EuiTitle size="xxxs">
|
||||
<h5>
|
||||
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
|
||||
defaultMessage: 'Top 5 values',
|
||||
})}
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
{details.error && <EuiText size="xs">{details.error}</EuiText>}
|
||||
{!details.error && (
|
||||
<>
|
||||
|
@ -70,6 +94,6 @@ export function DiscoverFieldDetails({
|
|||
</EuiText>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -6,12 +6,6 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export interface IndexPatternRef {
|
||||
id: string;
|
||||
title: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface FieldDetails {
|
||||
error: string;
|
||||
exists: number;
|
|
@ -9,18 +9,22 @@
|
|||
import { act } from 'react-dom/test-utils';
|
||||
import { EuiPopover, EuiProgress, EuiButtonIcon } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
||||
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { DiscoverField, DiscoverFieldProps } from './discover_field';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { DataDocuments$ } from '../../hooks/use_saved_search';
|
||||
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
|
||||
import * as DetailsUtil from './deprecated_stats/get_details';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
|
||||
jest.spyOn(DetailsUtil, 'getDetails');
|
||||
|
||||
jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
|
||||
loadFieldStats: jest.fn().mockResolvedValue({
|
||||
|
@ -42,8 +46,6 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const dataServiceMock = dataPluginMock.createStartContract();
|
||||
|
||||
jest.mock('../../../../kibana_services', () => ({
|
||||
getUiActions: jest.fn(() => {
|
||||
return {
|
||||
|
@ -80,10 +82,16 @@ async function getComponent({
|
|||
const dataView = stubDataView;
|
||||
dataView.toSpec = () => ({});
|
||||
|
||||
const hits = getDataTableRecords(dataView);
|
||||
const documents$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
result: hits,
|
||||
}) as DataDocuments$;
|
||||
|
||||
const props: DiscoverFieldProps = {
|
||||
documents$,
|
||||
dataView: stubDataView,
|
||||
field: finalField,
|
||||
getDetails: jest.fn(() => ({ buckets: [], error: '', exists: 1, total: 2 })),
|
||||
...(onAddFilterExists && { onAddFilter: jest.fn() }),
|
||||
onAddField: jest.fn(),
|
||||
onEditField: jest.fn(),
|
||||
|
@ -93,11 +101,7 @@ async function getComponent({
|
|||
contextualFields: [],
|
||||
};
|
||||
const services = {
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
}),
|
||||
...createDiscoverServicesMock(),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
|
@ -113,29 +117,6 @@ async function getComponent({
|
|||
}
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...dataServiceMock,
|
||||
query: {
|
||||
...dataServiceMock.query,
|
||||
timefilter: {
|
||||
...dataServiceMock.query.timefilter,
|
||||
timefilter: {
|
||||
...dataServiceMock.query.timefilter.timefilter,
|
||||
getAbsoluteTime: () => ({
|
||||
from: '2021-08-31T22:00:00.000Z',
|
||||
to: '2022-09-01T09:16:29.553Z',
|
||||
}),
|
||||
},
|
||||
},
|
||||
getState: () => ({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
dataViews: dataViewPluginMocks.createStartContract(),
|
||||
fieldFormats: fieldFormatsServiceMock.createStartContract(),
|
||||
charts: chartPluginMock.createSetupContract(),
|
||||
};
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
|
@ -156,6 +137,10 @@ async function getComponent({
|
|||
}
|
||||
|
||||
describe('discover sidebar field', function () {
|
||||
beforeEach(() => {
|
||||
(DetailsUtil.getDetails as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('should allow selecting fields', async function () {
|
||||
const { comp, props } = await getComponent({});
|
||||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
|
@ -166,14 +151,15 @@ describe('discover sidebar field', function () {
|
|||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
expect(props.onRemoveField).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
it('should trigger getDetails', async function () {
|
||||
it('should trigger getDetails for showing the deprecated field stats', async function () {
|
||||
const { comp, props } = await getComponent({
|
||||
selected: true,
|
||||
showFieldStats: true,
|
||||
showLegacyFieldTopValues: true,
|
||||
});
|
||||
findTestSubject(comp, 'field-bytes-showDetails').simulate('click');
|
||||
expect(props.getDetails).toHaveBeenCalledWith(props.field);
|
||||
expect(DetailsUtil.getDetails).toHaveBeenCalledTimes(1);
|
||||
expect(findTestSubject(comp, `discoverFieldDetails-${props.field.name}`).exists()).toBeTruthy();
|
||||
});
|
||||
it('should not allow clicking on _source', async function () {
|
||||
const field = new DataViewField({
|
||||
|
@ -184,13 +170,13 @@ describe('discover sidebar field', function () {
|
|||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
});
|
||||
const { comp, props } = await getComponent({
|
||||
const { comp } = await getComponent({
|
||||
selected: true,
|
||||
field,
|
||||
showLegacyFieldTopValues: true,
|
||||
});
|
||||
findTestSubject(comp, 'field-_source-showDetails').simulate('click');
|
||||
expect(props.getDetails).not.toHaveBeenCalled();
|
||||
expect(DetailsUtil.getDetails).not.toHaveBeenCalledWith();
|
||||
});
|
||||
it('displays warning for conflicting fields', async function () {
|
||||
const field = new DataViewField({
|
||||
|
@ -209,16 +195,16 @@ describe('discover sidebar field', function () {
|
|||
expect(dscField.find('.kbnFieldButton__infoIcon').length).toEqual(1);
|
||||
});
|
||||
it('should not execute getDetails when rendered, since it can be expensive', async function () {
|
||||
const { props } = await getComponent({});
|
||||
expect(props.getDetails).toHaveBeenCalledTimes(0);
|
||||
await getComponent({});
|
||||
expect(DetailsUtil.getDetails).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('should execute getDetails when show details is requested', async function () {
|
||||
const { props, comp } = await getComponent({
|
||||
const { comp } = await getComponent({
|
||||
showFieldStats: true,
|
||||
showLegacyFieldTopValues: true,
|
||||
});
|
||||
findTestSubject(comp, 'field-bytes-showDetails').simulate('click');
|
||||
expect(props.getDetails).toHaveBeenCalledTimes(1);
|
||||
expect(DetailsUtil.getDetails).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should not return the popover if onAddFilter is not provided', async function () {
|
||||
const field = new DataViewField({
|
||||
|
|
|
@ -9,7 +9,14 @@
|
|||
import './discover_field.scss';
|
||||
|
||||
import React, { useState, useCallback, memo, useMemo } from 'react';
|
||||
import { EuiButtonIcon, EuiToolTip, EuiTitle, EuiIcon, EuiSpacer } from '@elastic/eui';
|
||||
import {
|
||||
EuiButtonIcon,
|
||||
EuiToolTip,
|
||||
EuiTitle,
|
||||
EuiIcon,
|
||||
EuiSpacer,
|
||||
EuiHighlight,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import classNames from 'classnames';
|
||||
|
@ -23,12 +30,12 @@ import {
|
|||
} from '@kbn/unified-field-list-plugin/public';
|
||||
import { DiscoverFieldStats } from './discover_field_stats';
|
||||
import { getTypeForFieldIcon } from '../../../../utils/get_type_for_field_icon';
|
||||
import { DiscoverFieldDetails } from './discover_field_details';
|
||||
import { FieldDetails } from './types';
|
||||
import { DiscoverFieldDetails } from './deprecated_stats/discover_field_details';
|
||||
import { getFieldTypeName } from '../../../../utils/get_field_type_name';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
import { type DataDocuments$ } from '../../hooks/use_saved_search';
|
||||
|
||||
function wrapOnDot(str?: string) {
|
||||
// u200B is a non-width white-space character, which allows
|
||||
|
@ -64,24 +71,31 @@ const DiscoverFieldTypeIcon: React.FC<{ field: DataViewField }> = memo(({ field
|
|||
);
|
||||
});
|
||||
|
||||
const FieldName: React.FC<{ field: DataViewField }> = memo(({ field }) => {
|
||||
const title =
|
||||
field.displayName !== field.name
|
||||
? i18n.translate('discover.field.title', {
|
||||
defaultMessage: '{fieldName} ({fieldDisplayName})',
|
||||
values: {
|
||||
fieldName: field.name,
|
||||
fieldDisplayName: field.displayName,
|
||||
},
|
||||
})
|
||||
: field.displayName;
|
||||
const FieldName: React.FC<{ field: DataViewField; highlight?: string }> = memo(
|
||||
({ field, highlight }) => {
|
||||
const title =
|
||||
field.displayName !== field.name
|
||||
? i18n.translate('discover.field.title', {
|
||||
defaultMessage: '{fieldName} ({fieldDisplayName})',
|
||||
values: {
|
||||
fieldName: field.name,
|
||||
fieldDisplayName: field.displayName,
|
||||
},
|
||||
})
|
||||
: field.displayName;
|
||||
|
||||
return (
|
||||
<span data-test-subj={`field-${field.name}`} title={title} className="dscSidebarField__name">
|
||||
{wrapOnDot(field.displayName)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<EuiHighlight
|
||||
search={wrapOnDot(highlight)}
|
||||
data-test-subj={`field-${field.name}`}
|
||||
title={title}
|
||||
className="dscSidebarField__name"
|
||||
>
|
||||
{wrapOnDot(field.displayName)}
|
||||
</EuiHighlight>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface ActionButtonProps {
|
||||
field: DataViewField;
|
||||
|
@ -128,6 +142,7 @@ const ActionButton: React.FC<ActionButtonProps> = memo(
|
|||
} else {
|
||||
return (
|
||||
<EuiToolTip
|
||||
key={`tooltip-${field.name}-${field.count || 0}-${isSelected}`}
|
||||
delay="long"
|
||||
content={i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
|
||||
defaultMessage: 'Remove field from table',
|
||||
|
@ -164,11 +179,10 @@ interface MultiFieldsProps {
|
|||
multiFields: NonNullable<DiscoverFieldProps['multiFields']>;
|
||||
toggleDisplay: (field: DataViewField) => void;
|
||||
alwaysShowActionButton: boolean;
|
||||
isDocumentRecord: boolean;
|
||||
}
|
||||
|
||||
const MultiFields: React.FC<MultiFieldsProps> = memo(
|
||||
({ multiFields, toggleDisplay, alwaysShowActionButton, isDocumentRecord }) => (
|
||||
({ multiFields, toggleDisplay, alwaysShowActionButton }) => (
|
||||
<React.Fragment>
|
||||
<EuiTitle size="xxxs">
|
||||
<h5>
|
||||
|
@ -184,7 +198,7 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
|
|||
className="dscSidebarItem dscSidebarItem--multi"
|
||||
isActive={false}
|
||||
dataTestSubj={`field-${entry.field.name}-showDetails`}
|
||||
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={entry.field} />}
|
||||
fieldIcon={<DiscoverFieldTypeIcon field={entry.field} />}
|
||||
fieldAction={
|
||||
<ActionButton
|
||||
field={entry.field}
|
||||
|
@ -202,6 +216,10 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
|
|||
);
|
||||
|
||||
export interface DiscoverFieldProps {
|
||||
/**
|
||||
* hits fetched from ES, displayed in the doc table
|
||||
*/
|
||||
documents$: DataDocuments$;
|
||||
/**
|
||||
* Determines whether add/remove button is displayed not only when focused
|
||||
*/
|
||||
|
@ -227,10 +245,6 @@ export interface DiscoverFieldProps {
|
|||
* @param fieldName
|
||||
*/
|
||||
onRemoveField: (fieldName: string) => void;
|
||||
/**
|
||||
* Retrieve details data for the field
|
||||
*/
|
||||
getDetails: (field: DataViewField) => FieldDetails;
|
||||
/**
|
||||
* Determines whether the field is selected
|
||||
*/
|
||||
|
@ -264,16 +278,22 @@ export interface DiscoverFieldProps {
|
|||
* Columns
|
||||
*/
|
||||
contextualFields: string[];
|
||||
|
||||
/**
|
||||
* Search by field name
|
||||
*/
|
||||
highlight?: string;
|
||||
}
|
||||
|
||||
function DiscoverFieldComponent({
|
||||
documents$,
|
||||
alwaysShowActionButton = false,
|
||||
field,
|
||||
highlight,
|
||||
dataView,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onAddFilter,
|
||||
getDetails,
|
||||
selected,
|
||||
trackUiMetric,
|
||||
multiFields,
|
||||
|
@ -345,7 +365,7 @@ function DiscoverFieldComponent({
|
|||
size="s"
|
||||
className="dscSidebarItem"
|
||||
dataTestSubj={`field-${field.name}-showDetails`}
|
||||
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={field} />}
|
||||
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
|
||||
fieldAction={
|
||||
<ActionButton
|
||||
field={field}
|
||||
|
@ -364,9 +384,9 @@ function DiscoverFieldComponent({
|
|||
size="s"
|
||||
className="dscSidebarItem"
|
||||
isActive={infoIsOpen}
|
||||
onClick={togglePopover}
|
||||
onClick={isDocumentRecord ? togglePopover : undefined}
|
||||
dataTestSubj={`field-${field.name}-showDetails`}
|
||||
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={field} />}
|
||||
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
|
||||
fieldAction={
|
||||
<ActionButton
|
||||
field={field}
|
||||
|
@ -375,7 +395,7 @@ function DiscoverFieldComponent({
|
|||
toggleDisplay={toggleDisplay}
|
||||
/>
|
||||
}
|
||||
fieldName={<FieldName field={field} />}
|
||||
fieldName={<FieldName field={field} highlight={highlight} />}
|
||||
fieldInfoIcon={field.type === 'conflict' && <FieldInfoIcon />}
|
||||
/>
|
||||
);
|
||||
|
@ -389,24 +409,15 @@ function DiscoverFieldComponent({
|
|||
|
||||
return (
|
||||
<>
|
||||
{showLegacyFieldStats ? (
|
||||
{showLegacyFieldStats ? ( // TODO: Deprecate and remove after ~v8.7
|
||||
<>
|
||||
{showFieldStats && (
|
||||
<>
|
||||
<EuiTitle size="xxxs">
|
||||
<h5>
|
||||
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
|
||||
defaultMessage: 'Top 5 values',
|
||||
})}
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<DiscoverFieldDetails
|
||||
dataView={dataView}
|
||||
field={field}
|
||||
details={getDetails(field)}
|
||||
onAddFilter={onAddFilter}
|
||||
/>
|
||||
</>
|
||||
<DiscoverFieldDetails
|
||||
documents$={documents$}
|
||||
dataView={dataView}
|
||||
field={field}
|
||||
onAddFilter={onAddFilter}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
|
@ -425,7 +436,6 @@ function DiscoverFieldComponent({
|
|||
multiFields={multiFields}
|
||||
alwaysShowActionButton={alwaysShowActionButton}
|
||||
toggleDisplay={toggleDisplay}
|
||||
isDocumentRecord={isDocumentRecord}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -44,6 +44,7 @@ describe('DiscoverFieldSearch', () => {
|
|||
const input = findTestSubject(component, 'fieldFilterSearchInput');
|
||||
input.simulate('change', { target: { value: 'new filter' } });
|
||||
expect(defaultProps.onChange).toBeCalledTimes(1);
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith('name', 'new filter');
|
||||
});
|
||||
|
||||
test('change in active filters should change facet selection and call onChange', () => {
|
||||
|
@ -97,30 +98,17 @@ describe('DiscoverFieldSearch', () => {
|
|||
expect(badge.text()).toEqual('1');
|
||||
});
|
||||
|
||||
test('change in missing fields switch should not change filter count', () => {
|
||||
const component = mountComponent();
|
||||
const btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const badge = btn.find('.euiNotificationBadge').last();
|
||||
expect(badge.text()).toEqual('0');
|
||||
const missingSwitch = findTestSubject(component, 'missingSwitch');
|
||||
missingSwitch.simulate('change', { target: { value: false } });
|
||||
expect(badge.text()).toEqual('0');
|
||||
});
|
||||
|
||||
test('change in filters triggers onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
const component = mountComponent({ ...defaultProps, ...{ onChange } });
|
||||
const btn = findTestSubject(component, 'toggleFieldFilterButton');
|
||||
btn.simulate('click');
|
||||
const aggregtableButtonGroup = findButtonGroup(component, 'aggregatable');
|
||||
const missingSwitch = findTestSubject(component, 'missingSwitch');
|
||||
act(() => {
|
||||
// @ts-expect-error
|
||||
(aggregtableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null);
|
||||
});
|
||||
missingSwitch.simulate('click');
|
||||
expect(onChange).toBeCalledTimes(2);
|
||||
expect(onChange).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
test('change in type filters triggers onChange with appropriate value', () => {
|
||||
|
|
|
@ -17,11 +17,8 @@ import {
|
|||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
EuiPopoverFooter,
|
||||
EuiPopoverTitle,
|
||||
EuiSelect,
|
||||
EuiSwitch,
|
||||
EuiSwitchEvent,
|
||||
EuiForm,
|
||||
EuiFormRow,
|
||||
EuiButtonGroup,
|
||||
|
@ -43,7 +40,6 @@ export interface State {
|
|||
searchable: string;
|
||||
aggregatable: string;
|
||||
type: string;
|
||||
missing: boolean;
|
||||
[index: string]: string | boolean;
|
||||
}
|
||||
|
||||
|
@ -68,6 +64,11 @@ export interface Props {
|
|||
* is text base lang mode
|
||||
*/
|
||||
isPlainRecord: boolean;
|
||||
|
||||
/**
|
||||
* For a11y
|
||||
*/
|
||||
fieldSearchDescriptionId?: string;
|
||||
}
|
||||
|
||||
interface FieldTypeTableItem {
|
||||
|
@ -86,6 +87,7 @@ export function DiscoverFieldSearch({
|
|||
types,
|
||||
presentFieldTypes,
|
||||
isPlainRecord,
|
||||
fieldSearchDescriptionId,
|
||||
}: Props) {
|
||||
const searchPlaceholder = i18n.translate('discover.fieldChooser.searchPlaceHolder', {
|
||||
defaultMessage: 'Search field names',
|
||||
|
@ -112,7 +114,6 @@ export function DiscoverFieldSearch({
|
|||
searchable: 'any',
|
||||
aggregatable: 'any',
|
||||
type: 'any',
|
||||
missing: true,
|
||||
});
|
||||
|
||||
const { docLinks } = useDiscoverServices();
|
||||
|
@ -191,7 +192,7 @@ export function DiscoverFieldSearch({
|
|||
};
|
||||
|
||||
const isFilterActive = (name: string, filterValue: string | boolean) => {
|
||||
return name !== 'missing' && filterValue !== 'any';
|
||||
return filterValue !== 'any';
|
||||
};
|
||||
|
||||
const handleValueChange = (name: string, filterValue: string | boolean) => {
|
||||
|
@ -214,11 +215,6 @@ export function DiscoverFieldSearch({
|
|||
setActiveFiltersCount(activeFiltersCount + diff);
|
||||
};
|
||||
|
||||
const handleMissingChange = (e: EuiSwitchEvent) => {
|
||||
const missingValue = e.target.checked;
|
||||
handleValueChange('missing', missingValue);
|
||||
};
|
||||
|
||||
const buttonContent = (
|
||||
<EuiFilterButton
|
||||
aria-label={filterBtnAriaLabel}
|
||||
|
@ -297,21 +293,6 @@ export function DiscoverFieldSearch({
|
|||
);
|
||||
};
|
||||
|
||||
const footer = () => {
|
||||
return (
|
||||
<EuiPopoverFooter paddingSize="s">
|
||||
<EuiSwitch
|
||||
label={i18n.translate('discover.fieldChooser.filter.hideEmptyFieldsLabel', {
|
||||
defaultMessage: 'Hide empty fields',
|
||||
})}
|
||||
checked={values.missing}
|
||||
onChange={handleMissingChange}
|
||||
data-test-subj="missingSwitch"
|
||||
/>
|
||||
</EuiPopoverFooter>
|
||||
);
|
||||
};
|
||||
|
||||
const selectionPanel = (
|
||||
<div className="dscFieldSearch__formWrapper">
|
||||
<EuiForm data-test-subj="filterSelectionPanel">
|
||||
|
@ -353,10 +334,11 @@ export function DiscoverFieldSearch({
|
|||
<EuiFlexGroup responsive={false} gutterSize={'s'}>
|
||||
<EuiFlexItem>
|
||||
<EuiFieldSearch
|
||||
aria-describedby={fieldSearchDescriptionId}
|
||||
aria-label={searchPlaceholder}
|
||||
data-test-subj="fieldFilterSearchInput"
|
||||
fullWidth
|
||||
onChange={(event) => onChange('name', event.currentTarget.value)}
|
||||
onChange={(event) => onChange('name', event.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
value={value}
|
||||
/>
|
||||
|
@ -384,7 +366,6 @@ export function DiscoverFieldSearch({
|
|||
})}
|
||||
</EuiPopoverTitle>
|
||||
{selectionPanel}
|
||||
{footer()}
|
||||
</EuiPopover>
|
||||
<EuiPopover
|
||||
anchorPosition="rightUp"
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
FieldStats,
|
||||
FieldStatsProps,
|
||||
useQuerySubscriber,
|
||||
hasQuerySubscriberData,
|
||||
} from '@kbn/unified-field-list-plugin/public';
|
||||
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
|
@ -25,7 +26,6 @@ export interface DiscoverFieldStatsProps {
|
|||
export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
|
||||
({ field, dataView, multiFields, onAddFilter }) => {
|
||||
const services = useDiscoverServices();
|
||||
const dateRange = services.data?.query?.timefilter.timefilter.getAbsoluteTime();
|
||||
const querySubscriberResult = useQuerySubscriber({
|
||||
data: services.data,
|
||||
});
|
||||
|
@ -38,7 +38,7 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
|
|||
[field, multiFields]
|
||||
);
|
||||
|
||||
if (!dateRange || !querySubscriberResult.query || !querySubscriberResult.filters) {
|
||||
if (!hasQuerySubscriberData(querySubscriberResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -47,8 +47,8 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
|
|||
services={services}
|
||||
query={querySubscriberResult.query}
|
||||
filters={querySubscriberResult.filters}
|
||||
fromDate={dateRange.from}
|
||||
toDate={dateRange.to}
|
||||
fromDate={querySubscriberResult.fromDate}
|
||||
toDate={querySubscriberResult.toDate}
|
||||
dataViewOrDataViewId={dataView}
|
||||
field={fieldForStats}
|
||||
data-test-subj="dscFieldStats"
|
||||
|
|
|
@ -31,20 +31,6 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.dscFieldList {
|
||||
padding: 0 $euiSizeXS $euiSizeXS;
|
||||
}
|
||||
|
||||
.dscFieldListHeader {
|
||||
padding: $euiSizeS $euiSizeS 0 $euiSizeS;
|
||||
background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade);
|
||||
}
|
||||
|
||||
.dscFieldList--popular {
|
||||
padding-bottom: $euiSizeS;
|
||||
background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade);
|
||||
}
|
||||
|
||||
.dscSidebarItem {
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
|
|
|
@ -6,30 +6,38 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { Action } from '@kbn/ui-actions-plugin/public';
|
||||
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import React from 'react';
|
||||
import { DiscoverSidebarProps } from './discover_sidebar';
|
||||
import {
|
||||
DiscoverSidebarComponent as DiscoverSidebar,
|
||||
DiscoverSidebarProps,
|
||||
} from './discover_sidebar';
|
||||
import { DataViewListItem } from '@kbn/data-views-plugin/public';
|
||||
|
||||
import type { AggregateQuery, Query } from '@kbn/es-query';
|
||||
import { getDefaultFieldFilter } from './lib/field_filter';
|
||||
import { DiscoverSidebarComponent as DiscoverSidebar } from './discover_sidebar';
|
||||
import { discoverServiceMock as mockDiscoverServices } from '../../../../__mocks__/services';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
|
||||
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { AvailableFields$ } from '../../hooks/use_saved_search';
|
||||
import { AvailableFields$, DataDocuments$ } from '../../hooks/use_saved_search';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
|
||||
import * as ExistingFieldsHookApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
|
||||
import { ExistenceFetchStatus } from '@kbn/unified-field-list-plugin/public';
|
||||
import { getDataViewFieldList } from './lib/get_data_view_field_list';
|
||||
|
||||
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
|
||||
() => Promise.resolve([])
|
||||
);
|
||||
|
||||
jest.spyOn(ExistingFieldsHookApi, 'useExistingFieldsReader');
|
||||
|
||||
jest.mock('../../../../kibana_services', () => ({
|
||||
getUiActions: () => ({
|
||||
getTriggerCompatibleActions: mockGetActions,
|
||||
|
@ -41,12 +49,6 @@ function getCompProps(): DiscoverSidebarProps {
|
|||
dataView.toSpec = jest.fn(() => ({}));
|
||||
const hits = getDataTableRecords(dataView);
|
||||
|
||||
const dataViewList = [
|
||||
{ id: '0', title: 'b' } as DataViewListItem,
|
||||
{ id: '1', title: 'a' } as DataViewListItem,
|
||||
{ id: '2', title: 'c' } as DataViewListItem,
|
||||
];
|
||||
|
||||
const fieldCounts: Record<string, number> = {};
|
||||
|
||||
for (const hit of hits) {
|
||||
|
@ -54,16 +56,36 @@ function getCompProps(): DiscoverSidebarProps {
|
|||
fieldCounts[key] = (fieldCounts[key] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
const allFields = getDataViewFieldList(dataView, fieldCounts, false);
|
||||
|
||||
(ExistingFieldsHookApi.useExistingFieldsReader as jest.Mock).mockClear();
|
||||
(ExistingFieldsHookApi.useExistingFieldsReader as jest.Mock).mockImplementation(() => ({
|
||||
hasFieldData: (dataViewId: string, fieldName: string) => {
|
||||
return dataViewId === dataView.id && Object.keys(fieldCounts).includes(fieldName);
|
||||
},
|
||||
getFieldsExistenceStatus: (dataViewId: string) => {
|
||||
return dataViewId === dataView.id
|
||||
? ExistenceFetchStatus.succeeded
|
||||
: ExistenceFetchStatus.unknown;
|
||||
},
|
||||
isFieldsExistenceInfoUnavailable: (dataViewId: string) => dataViewId !== dataView.id,
|
||||
}));
|
||||
|
||||
const availableFields$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
fields: [] as string[],
|
||||
}) as AvailableFields$;
|
||||
|
||||
const documents$ = new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
result: hits,
|
||||
}) as DataDocuments$;
|
||||
|
||||
return {
|
||||
columns: ['extension'],
|
||||
fieldCounts,
|
||||
documents: hits,
|
||||
dataViewList,
|
||||
allFields,
|
||||
dataViewList: [dataView as DataViewListItem],
|
||||
onChangeDataView: jest.fn(),
|
||||
onAddFilter: jest.fn(),
|
||||
onAddField: jest.fn(),
|
||||
|
@ -77,37 +99,38 @@ function getCompProps(): DiscoverSidebarProps {
|
|||
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
|
||||
createNewDataView: jest.fn(),
|
||||
onDataViewCreated: jest.fn(),
|
||||
documents$,
|
||||
availableFields$,
|
||||
useNewFieldsApi: true,
|
||||
showFieldList: true,
|
||||
isAffectedByGlobalFilter: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getAppStateContainer() {
|
||||
function getAppStateContainer({ query }: { query?: Query | AggregateQuery }) {
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
query: { query: '', language: 'lucene' },
|
||||
query: query ?? { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
});
|
||||
return appStateContainer;
|
||||
}
|
||||
|
||||
describe('discover sidebar', function () {
|
||||
let props: DiscoverSidebarProps;
|
||||
async function mountComponent(
|
||||
props: DiscoverSidebarProps,
|
||||
appStateParams: { query?: Query | AggregateQuery } = {}
|
||||
): Promise<ReactWrapper<DiscoverSidebarProps>> {
|
||||
let comp: ReactWrapper<DiscoverSidebarProps>;
|
||||
const mockedServices = createDiscoverServicesMock();
|
||||
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () => props.dataViewList);
|
||||
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
|
||||
return [props.selectedDataView].find((d) => d!.id === id);
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
props = getCompProps();
|
||||
mockDiscoverServices.data.dataViews.getIdsWithTitle = jest
|
||||
.fn()
|
||||
.mockReturnValue(props.dataViewList);
|
||||
mockDiscoverServices.data.dataViews.get = jest.fn().mockImplementation((id) => {
|
||||
const dataView = props.dataViewList.find((d) => d.id === id);
|
||||
return { ...dataView, isPersisted: () => true };
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
comp = await mountWithIntl(
|
||||
<KibanaContextProvider services={mockDiscoverServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer()}>
|
||||
<KibanaContextProvider services={mockedServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
|
||||
<DiscoverSidebar {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
|
@ -117,24 +140,50 @@ describe('discover sidebar', function () {
|
|||
await comp.update();
|
||||
});
|
||||
|
||||
it('should have Selected Fields and Available Fields with Popular Fields sections', function () {
|
||||
const popular = findTestSubject(comp, 'fieldList-popular');
|
||||
const selected = findTestSubject(comp, 'fieldList-selected');
|
||||
const unpopular = findTestSubject(comp, 'fieldList-unpopular');
|
||||
expect(popular.children().length).toBe(1);
|
||||
expect(unpopular.children().length).toBe(6);
|
||||
expect(selected.children().length).toBe(1);
|
||||
await comp!.update();
|
||||
|
||||
return comp!;
|
||||
}
|
||||
|
||||
describe('discover sidebar', function () {
|
||||
let props: DiscoverSidebarProps;
|
||||
|
||||
beforeEach(async () => {
|
||||
props = getCompProps();
|
||||
});
|
||||
it('should allow selecting fields', function () {
|
||||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
|
||||
it('should hide field list', async function () {
|
||||
const comp = await mountComponent({
|
||||
...props,
|
||||
showFieldList: false,
|
||||
});
|
||||
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(false);
|
||||
});
|
||||
it('should have Selected Fields and Available Fields with Popular Fields sections', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const popularFieldsCount = findTestSubject(comp, 'fieldListGroupedPopularFields-count');
|
||||
const selectedFieldsCount = findTestSubject(comp, 'fieldListGroupedSelectedFields-count');
|
||||
const availableFieldsCount = findTestSubject(comp, 'fieldListGroupedAvailableFields-count');
|
||||
expect(popularFieldsCount.text()).toBe('4');
|
||||
expect(availableFieldsCount.text()).toBe('3');
|
||||
expect(selectedFieldsCount.text()).toBe('1');
|
||||
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(true);
|
||||
});
|
||||
it('should allow selecting fields', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
findTestSubject(availableFields, 'fieldToggle-bytes').simulate('click');
|
||||
expect(props.onAddField).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
it('should allow deselecting fields', function () {
|
||||
findTestSubject(comp, 'fieldToggle-extension').simulate('click');
|
||||
it('should allow deselecting fields', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
findTestSubject(availableFields, 'fieldToggle-extension').simulate('click');
|
||||
expect(props.onRemoveField).toHaveBeenCalledWith('extension');
|
||||
});
|
||||
|
||||
it('should render "Add a field" button', () => {
|
||||
it('should render "Add a field" button', async () => {
|
||||
const comp = await mountComponent(props);
|
||||
const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
|
||||
expect(addFieldButton.length).toBe(1);
|
||||
addFieldButton.simulate('click');
|
||||
|
@ -142,8 +191,11 @@ describe('discover sidebar', function () {
|
|||
});
|
||||
|
||||
it('should render "Edit field" button', async () => {
|
||||
findTestSubject(comp, 'field-bytes').simulate('click');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
findTestSubject(availableFields, 'field-bytes').simulate('click');
|
||||
});
|
||||
await comp.update();
|
||||
const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes');
|
||||
expect(editFieldButton.length).toBe(1);
|
||||
|
@ -151,29 +203,27 @@ describe('discover sidebar', function () {
|
|||
expect(props.editField).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
|
||||
it('should not render Add/Edit field buttons in viewer mode', () => {
|
||||
const compInViewerMode = mountWithIntl(
|
||||
<KibanaContextProvider services={mockDiscoverServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer()}>
|
||||
<DiscoverSidebar {...props} editField={undefined} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
it('should not render Add/Edit field buttons in viewer mode', async () => {
|
||||
const compInViewerMode = await mountComponent({
|
||||
...getCompProps(),
|
||||
editField: undefined,
|
||||
});
|
||||
const addFieldButton = findTestSubject(compInViewerMode, 'dataView-add-field_btn');
|
||||
expect(addFieldButton.length).toBe(0);
|
||||
findTestSubject(comp, 'field-bytes').simulate('click');
|
||||
const availableFields = findTestSubject(compInViewerMode, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
findTestSubject(availableFields, 'field-bytes').simulate('click');
|
||||
});
|
||||
const editFieldButton = findTestSubject(compInViewerMode, 'discoverFieldListPanelEdit-bytes');
|
||||
expect(editFieldButton.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should render buttons in data view picker correctly', async () => {
|
||||
const compWithPicker = mountWithIntl(
|
||||
<KibanaContextProvider services={mockDiscoverServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer()}>
|
||||
<DiscoverSidebar {...props} showDataViewPicker />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
const propsWithPicker = {
|
||||
...getCompProps(),
|
||||
showDataViewPicker: true,
|
||||
};
|
||||
const compWithPicker = await mountComponent(propsWithPicker);
|
||||
// open data view picker
|
||||
findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click');
|
||||
expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1);
|
||||
|
@ -184,27 +234,21 @@ describe('discover sidebar', function () {
|
|||
);
|
||||
expect(addFieldButtonInDataViewPicker.length).toBe(1);
|
||||
addFieldButtonInDataViewPicker.simulate('click');
|
||||
expect(props.editField).toHaveBeenCalledWith();
|
||||
expect(propsWithPicker.editField).toHaveBeenCalledWith();
|
||||
// click "Create a data view"
|
||||
const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new');
|
||||
expect(createDataViewButton.length).toBe(1);
|
||||
createDataViewButton.simulate('click');
|
||||
expect(props.createNewDataView).toHaveBeenCalled();
|
||||
expect(propsWithPicker.createNewDataView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render buttons in data view picker when in viewer mode', async () => {
|
||||
const compWithPickerInViewerMode = mountWithIntl(
|
||||
<KibanaContextProvider services={mockDiscoverServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer()}>
|
||||
<DiscoverSidebar
|
||||
{...props}
|
||||
showDataViewPicker
|
||||
editField={undefined}
|
||||
createNewDataView={undefined}
|
||||
/>
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
const compWithPickerInViewerMode = await mountComponent({
|
||||
...getCompProps(),
|
||||
showDataViewPicker: true,
|
||||
editField: undefined,
|
||||
createNewDataView: undefined,
|
||||
});
|
||||
// open data view picker
|
||||
findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click');
|
||||
expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1);
|
||||
|
@ -218,14 +262,10 @@ describe('discover sidebar', function () {
|
|||
expect(createDataViewButton.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should render the Visualize in Lens button in text based languages mode', () => {
|
||||
const compInViewerMode = mountWithIntl(
|
||||
<KibanaContextProvider services={mockDiscoverServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer()}>
|
||||
<DiscoverSidebar {...props} onAddFilter={undefined} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
it('should render the Visualize in Lens button in text based languages mode', async () => {
|
||||
const compInViewerMode = await mountComponent(getCompProps(), {
|
||||
query: { sql: 'SELECT * FROM test' },
|
||||
});
|
||||
const visualizeField = findTestSubject(compInViewerMode, 'textBased-visualize');
|
||||
expect(visualizeField.length).toBe(1);
|
||||
});
|
||||
|
|
|
@ -7,49 +7,48 @@
|
|||
*/
|
||||
|
||||
import './discover_sidebar.scss';
|
||||
import { throttle } from 'lodash';
|
||||
import React, { useCallback, useEffect, useState, useMemo, useRef, memo } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
EuiAccordion,
|
||||
EuiFlexItem,
|
||||
EuiFlexGroup,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiSpacer,
|
||||
EuiNotificationBadge,
|
||||
EuiPageSideBar_Deprecated as EuiPageSideBar,
|
||||
useResizeObserver,
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPageSideBar_Deprecated as EuiPageSideBar,
|
||||
htmlIdGenerator,
|
||||
} from '@elastic/eui';
|
||||
import { isOfAggregateQueryType } from '@kbn/es-query';
|
||||
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
|
||||
import { isEqual } from 'lodash';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
|
||||
import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
|
||||
import { triggerVisualizeActionsTextBasedLanguages } from '@kbn/unified-field-list-plugin/public';
|
||||
import { type DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
FieldListGrouped,
|
||||
FieldListGroupedProps,
|
||||
FieldsGroupNames,
|
||||
GroupedFieldsParams,
|
||||
triggerVisualizeActionsTextBasedLanguages,
|
||||
useExistingFieldsReader,
|
||||
useGroupedFields,
|
||||
} from '@kbn/unified-field-list-plugin/public';
|
||||
import { useAppStateSelector } from '../../services/discover_app_state_container';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { DiscoverField } from './discover_field';
|
||||
import { DiscoverFieldSearch } from './discover_field_search';
|
||||
import { FIELDS_LIMIT_SETTING, PLUGIN_ID } from '../../../../../common';
|
||||
import { groupFields } from './lib/group_fields';
|
||||
import { getDetails } from './lib/get_details';
|
||||
import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter';
|
||||
import { getDataViewFieldList } from './lib/get_data_view_field_list';
|
||||
import {
|
||||
getSelectedFields,
|
||||
shouldShowField,
|
||||
type SelectedFieldsResult,
|
||||
INITIAL_SELECTED_FIELDS_RESULT,
|
||||
} from './lib/group_fields';
|
||||
import { doesFieldMatchFilters, FieldFilterState, setFieldFilterProp } from './lib/field_filter';
|
||||
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
|
||||
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
|
||||
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
|
||||
import type { DataTableRecord } from '../../../../types';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
import { getRawRecordType } from '../../utils/get_raw_record_type';
|
||||
import { RecordRawType } from '../../hooks/use_saved_search';
|
||||
|
||||
/**
|
||||
* Default number of available fields displayed and added on scroll
|
||||
*/
|
||||
const FIELDS_PER_PAGE = 50;
|
||||
const fieldSearchDescriptionId = htmlIdGenerator()();
|
||||
|
||||
export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProps, 'documents$'> {
|
||||
export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
|
||||
/**
|
||||
* Current state of the field filter, filtering fields by name, type, ...
|
||||
*/
|
||||
|
@ -84,27 +83,37 @@ export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProp
|
|||
createNewDataView?: () => void;
|
||||
|
||||
/**
|
||||
* a statistics of the distribution of fields in the given hits
|
||||
* All fields: fields from data view and unmapped fields
|
||||
*/
|
||||
fieldCounts?: Record<string, number>;
|
||||
/**
|
||||
* hits fetched from ES, displayed in the doc table
|
||||
*/
|
||||
documents?: DataTableRecord[];
|
||||
allFields: DataViewField[] | null;
|
||||
|
||||
/**
|
||||
* Discover view mode
|
||||
*/
|
||||
viewMode: VIEW_MODE;
|
||||
|
||||
/**
|
||||
* Show data view picker (for mobile view)
|
||||
*/
|
||||
showDataViewPicker?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to render the field list or not (we don't show it unless documents are loaded)
|
||||
*/
|
||||
showFieldList?: boolean;
|
||||
|
||||
/**
|
||||
* Whether filters are applied
|
||||
*/
|
||||
isAffectedByGlobalFilter: boolean;
|
||||
}
|
||||
|
||||
export function DiscoverSidebarComponent({
|
||||
alwaysShowActionButtons = false,
|
||||
columns,
|
||||
fieldCounts,
|
||||
fieldFilter,
|
||||
documents,
|
||||
documents$,
|
||||
allFields,
|
||||
onAddField,
|
||||
onAddFilter,
|
||||
onRemoveField,
|
||||
|
@ -120,108 +129,28 @@ export function DiscoverSidebarComponent({
|
|||
viewMode,
|
||||
createNewDataView,
|
||||
showDataViewPicker,
|
||||
showFieldList,
|
||||
isAffectedByGlobalFilter,
|
||||
}: DiscoverSidebarProps) {
|
||||
const { uiSettings, dataViewFieldEditor } = useDiscoverServices();
|
||||
const [fields, setFields] = useState<DataViewField[] | null>(null);
|
||||
const [scrollContainer, setScrollContainer] = useState<Element | null>(null);
|
||||
const [fieldsToRender, setFieldsToRender] = useState(FIELDS_PER_PAGE);
|
||||
const [fieldsPerPage, setFieldsPerPage] = useState(FIELDS_PER_PAGE);
|
||||
const availableFieldsContainer = useRef<HTMLUListElement | null>(null);
|
||||
const isPlainRecord = !onAddFilter;
|
||||
const { uiSettings, dataViewFieldEditor, dataViews } = useDiscoverServices();
|
||||
const isPlainRecord = useAppStateSelector(
|
||||
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
|
||||
);
|
||||
const query = useAppStateSelector((state) => state.query);
|
||||
|
||||
useEffect(() => {
|
||||
if (documents) {
|
||||
const newFields = getDataViewFieldList(selectedDataView, fieldCounts);
|
||||
setFields(newFields);
|
||||
}
|
||||
}, [selectedDataView, fieldCounts, documents]);
|
||||
|
||||
const scrollDimensions = useResizeObserver(scrollContainer);
|
||||
|
||||
const onChangeFieldSearch = useCallback(
|
||||
(field: string, value: string | boolean | undefined) => {
|
||||
const newState = setFieldFilterProp(fieldFilter, field, value);
|
||||
(filterName: string, value: string | boolean | undefined) => {
|
||||
const newState = setFieldFilterProp(fieldFilter, filterName, value);
|
||||
setFieldFilter(newState);
|
||||
setFieldsToRender(fieldsPerPage);
|
||||
},
|
||||
[fieldFilter, setFieldFilter, setFieldsToRender, fieldsPerPage]
|
||||
[fieldFilter, setFieldFilter]
|
||||
);
|
||||
|
||||
const getDetailsByField = useCallback(
|
||||
(ipField: DataViewField) => getDetails(ipField, documents, selectedDataView),
|
||||
[documents, selectedDataView]
|
||||
);
|
||||
|
||||
const popularLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
|
||||
|
||||
const {
|
||||
selected: selectedFields,
|
||||
popular: popularFields,
|
||||
unpopular: unpopularFields,
|
||||
} = useMemo(
|
||||
() => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi),
|
||||
[fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi]
|
||||
);
|
||||
|
||||
/**
|
||||
* Popular fields are not displayed in text based lang mode
|
||||
*/
|
||||
const restFields = useMemo(
|
||||
() => (isPlainRecord ? [...popularFields, ...unpopularFields] : unpopularFields),
|
||||
[isPlainRecord, popularFields, unpopularFields]
|
||||
);
|
||||
|
||||
const paginate = useCallback(() => {
|
||||
const newFieldsToRender = fieldsToRender + Math.round(fieldsPerPage * 0.5);
|
||||
setFieldsToRender(Math.max(fieldsPerPage, Math.min(newFieldsToRender, restFields.length)));
|
||||
}, [setFieldsToRender, fieldsToRender, restFields, fieldsPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainer && restFields.length && availableFieldsContainer.current) {
|
||||
const { clientHeight, scrollHeight } = scrollContainer;
|
||||
const isScrollable = scrollHeight > clientHeight; // there is no scrolling currently
|
||||
const allFieldsRendered = fieldsToRender >= restFields.length;
|
||||
|
||||
if (!isScrollable && !allFieldsRendered) {
|
||||
// Not all available fields were rendered with the given fieldsPerPage number
|
||||
// and no scrolling is available due to the a high zoom out factor of the browser
|
||||
// In this case the fieldsPerPage needs to be adapted
|
||||
const fieldsRenderedHeight = availableFieldsContainer.current.clientHeight;
|
||||
const avgHeightPerItem = Math.round(fieldsRenderedHeight / fieldsToRender);
|
||||
const newFieldsPerPage =
|
||||
(avgHeightPerItem > 0 ? Math.round(clientHeight / avgHeightPerItem) : 0) + 10;
|
||||
if (newFieldsPerPage >= FIELDS_PER_PAGE && newFieldsPerPage !== fieldsPerPage) {
|
||||
setFieldsPerPage(newFieldsPerPage);
|
||||
setFieldsToRender(newFieldsPerPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
fieldsPerPage,
|
||||
scrollContainer,
|
||||
restFields,
|
||||
fieldsToRender,
|
||||
setFieldsPerPage,
|
||||
setFieldsToRender,
|
||||
scrollDimensions,
|
||||
]);
|
||||
|
||||
const lazyScroll = useCallback(() => {
|
||||
if (scrollContainer) {
|
||||
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
|
||||
const nearBottom = scrollTop + clientHeight > scrollHeight * 0.9;
|
||||
if (nearBottom && restFields) {
|
||||
paginate();
|
||||
}
|
||||
}
|
||||
}, [paginate, scrollContainer, restFields]);
|
||||
|
||||
const { fieldTypes, presentFieldTypes } = useMemo(() => {
|
||||
const result = ['any'];
|
||||
const dataViewFieldTypes = new Set<string>();
|
||||
if (Array.isArray(fields)) {
|
||||
for (const field of fields) {
|
||||
if (Array.isArray(allFields)) {
|
||||
for (const field of allFields) {
|
||||
if (field.type !== '_source') {
|
||||
// If it's a string type, we want to distinguish between keyword and text
|
||||
// For this purpose we need the ES type
|
||||
|
@ -242,37 +171,36 @@ export function DiscoverSidebarComponent({
|
|||
}
|
||||
}
|
||||
return { fieldTypes: result, presentFieldTypes: Array.from(dataViewFieldTypes) };
|
||||
}, [fields]);
|
||||
}, [allFields]);
|
||||
|
||||
const showFieldStats = useMemo(() => viewMode === VIEW_MODE.DOCUMENT_LEVEL, [viewMode]);
|
||||
const [selectedFieldsState, setSelectedFieldsState] = useState<SelectedFieldsResult>(
|
||||
INITIAL_SELECTED_FIELDS_RESULT
|
||||
);
|
||||
const [multiFieldsMap, setMultiFieldsMap] = useState<
|
||||
Map<string, Array<{ field: DataViewField; isSelected: boolean }>> | undefined
|
||||
>(undefined);
|
||||
|
||||
const calculateMultiFields = () => {
|
||||
if (!useNewFieldsApi || !fields) {
|
||||
return undefined;
|
||||
useEffect(() => {
|
||||
const result = getSelectedFields(selectedDataView, columns);
|
||||
setSelectedFieldsState(result);
|
||||
}, [selectedDataView, columns, setSelectedFieldsState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlainRecord || !useNewFieldsApi) {
|
||||
setMultiFieldsMap(undefined); // we don't have to calculate multifields in this case
|
||||
} else {
|
||||
setMultiFieldsMap(
|
||||
calculateMultiFields(allFields, selectedFieldsState.selectedFieldsMap, useNewFieldsApi)
|
||||
);
|
||||
}
|
||||
const map = new Map<string, Array<{ field: DataViewField; isSelected: boolean }>>();
|
||||
fields.forEach((field) => {
|
||||
const subTypeMulti = getFieldSubtypeMulti(field);
|
||||
const parent = subTypeMulti?.multi.parent;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
const multiField = {
|
||||
field,
|
||||
isSelected: selectedFields.includes(field),
|
||||
};
|
||||
const value = map.get(parent) ?? [];
|
||||
value.push(multiField);
|
||||
map.set(parent, value);
|
||||
});
|
||||
return map;
|
||||
};
|
||||
|
||||
const [multiFields, setMultiFields] = useState(() => calculateMultiFields());
|
||||
|
||||
useShallowCompareEffect(() => {
|
||||
setMultiFields(calculateMultiFields());
|
||||
}, [fields, selectedFields, useNewFieldsApi]);
|
||||
}, [
|
||||
selectedFieldsState.selectedFieldsMap,
|
||||
allFields,
|
||||
useNewFieldsApi,
|
||||
setMultiFieldsMap,
|
||||
isPlainRecord,
|
||||
]);
|
||||
|
||||
const deleteField = useMemo(
|
||||
() =>
|
||||
|
@ -305,15 +233,6 @@ export function DiscoverSidebarComponent({
|
|||
]
|
||||
);
|
||||
|
||||
const getPaginated = useCallback(
|
||||
(list) => {
|
||||
return list.slice(0, fieldsToRender);
|
||||
},
|
||||
[fieldsToRender]
|
||||
);
|
||||
|
||||
const filterChanged = useMemo(() => isEqual(fieldFilter, getDefaultFieldFilter()), [fieldFilter]);
|
||||
|
||||
const visualizeAggregateQuery = useCallback(() => {
|
||||
const aggregateQuery = query && isOfAggregateQueryType(query) ? query : undefined;
|
||||
triggerVisualizeActionsTextBasedLanguages(
|
||||
|
@ -325,6 +244,89 @@ export function DiscoverSidebarComponent({
|
|||
);
|
||||
}, [columns, selectedDataView, query]);
|
||||
|
||||
const popularFieldsLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
|
||||
const onFilterField: GroupedFieldsParams<DataViewField>['onFilterField'] = useCallback(
|
||||
(field) => {
|
||||
return doesFieldMatchFilters(field, fieldFilter);
|
||||
},
|
||||
[fieldFilter]
|
||||
);
|
||||
const onSupportedFieldFilter: GroupedFieldsParams<DataViewField>['onSupportedFieldFilter'] =
|
||||
useCallback(
|
||||
(field) => {
|
||||
return shouldShowField(field, isPlainRecord);
|
||||
},
|
||||
[isPlainRecord]
|
||||
);
|
||||
const onOverrideFieldGroupDetails: GroupedFieldsParams<DataViewField>['onOverrideFieldGroupDetails'] =
|
||||
useCallback((groupName) => {
|
||||
if (groupName === FieldsGroupNames.AvailableFields) {
|
||||
return {
|
||||
helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', {
|
||||
defaultMessage: 'Fields available for display in the table.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
const fieldsExistenceReader = useExistingFieldsReader();
|
||||
const fieldListGroupedProps = useGroupedFields({
|
||||
dataViewId: (!isPlainRecord && selectedDataView?.id) || null, // passing `null` for text-based queries
|
||||
fieldsExistenceReader: !isPlainRecord ? fieldsExistenceReader : undefined,
|
||||
allFields,
|
||||
popularFieldsLimit: !isPlainRecord ? popularFieldsLimit : 0,
|
||||
sortedSelectedFields: selectedFieldsState.selectedFields,
|
||||
isAffectedByGlobalFilter,
|
||||
services: {
|
||||
dataViews,
|
||||
},
|
||||
onFilterField,
|
||||
onSupportedFieldFilter,
|
||||
onOverrideFieldGroupDetails,
|
||||
});
|
||||
|
||||
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
|
||||
({ field, groupName }) => (
|
||||
<li key={`field${field.name}`} data-attr-field={field.name}>
|
||||
<DiscoverField
|
||||
alwaysShowActionButton={alwaysShowActionButtons}
|
||||
field={field}
|
||||
highlight={fieldFilter.name}
|
||||
dataView={selectedDataView!}
|
||||
onAddField={onAddField}
|
||||
onRemoveField={onRemoveField}
|
||||
onAddFilter={onAddFilter}
|
||||
documents$={documents$}
|
||||
trackUiMetric={trackUiMetric}
|
||||
multiFields={multiFieldsMap?.get(field.name)} // ideally we better calculate multifields when they are requested first from the popover
|
||||
onEditField={editField}
|
||||
onDeleteField={deleteField}
|
||||
showFieldStats={showFieldStats}
|
||||
contextualFields={columns}
|
||||
selected={
|
||||
groupName === FieldsGroupNames.SelectedFields ||
|
||||
Boolean(selectedFieldsState.selectedFieldsMap[field.name])
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
),
|
||||
[
|
||||
alwaysShowActionButtons,
|
||||
selectedDataView,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onAddFilter,
|
||||
documents$,
|
||||
trackUiMetric,
|
||||
multiFieldsMap,
|
||||
editField,
|
||||
deleteField,
|
||||
showFieldStats,
|
||||
columns,
|
||||
selectedFieldsState.selectedFieldsMap,
|
||||
fieldFilter.name,
|
||||
]
|
||||
);
|
||||
|
||||
if (!selectedDataView) {
|
||||
return null;
|
||||
}
|
||||
|
@ -367,169 +369,18 @@ export function DiscoverSidebarComponent({
|
|||
types={fieldTypes}
|
||||
presentFieldTypes={presentFieldTypes}
|
||||
isPlainRecord={isPlainRecord}
|
||||
fieldSearchDescriptionId={fieldSearchDescriptionId}
|
||||
/>
|
||||
</form>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem className="eui-yScroll">
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (documents && el && !el.dataset.dynamicScroll) {
|
||||
el.dataset.dynamicScroll = 'true';
|
||||
setScrollContainer(el);
|
||||
}
|
||||
}}
|
||||
onScroll={throttle(lazyScroll, 100)}
|
||||
className="eui-yScroll"
|
||||
>
|
||||
{Array.isArray(fields) && fields.length > 0 && (
|
||||
<div>
|
||||
{selectedFields &&
|
||||
selectedFields.length > 0 &&
|
||||
selectedFields[0].displayName !== '_source' ? (
|
||||
<>
|
||||
<EuiAccordion
|
||||
id="dscSelectedFields"
|
||||
initialIsOpen={true}
|
||||
buttonContent={
|
||||
<EuiText size="xs" id="selected_fields">
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="discover.fieldChooser.filter.selectedFieldsTitle"
|
||||
defaultMessage="Selected fields"
|
||||
/>
|
||||
</strong>
|
||||
</EuiText>
|
||||
}
|
||||
extraAction={
|
||||
<EuiNotificationBadge color={filterChanged ? 'subdued' : 'accent'} size="m">
|
||||
{selectedFields.length}
|
||||
</EuiNotificationBadge>
|
||||
}
|
||||
>
|
||||
<EuiSpacer size="m" />
|
||||
<ul
|
||||
className="dscFieldList"
|
||||
aria-labelledby="selected_fields"
|
||||
data-test-subj={`fieldList-selected`}
|
||||
>
|
||||
{selectedFields.map((field: DataViewField) => {
|
||||
return (
|
||||
<li key={`field${field.name}`} data-attr-field={field.name}>
|
||||
<DiscoverField
|
||||
alwaysShowActionButton={alwaysShowActionButtons}
|
||||
field={field}
|
||||
dataView={selectedDataView}
|
||||
onAddField={onAddField}
|
||||
onRemoveField={onRemoveField}
|
||||
onAddFilter={onAddFilter}
|
||||
getDetails={getDetailsByField}
|
||||
selected={true}
|
||||
trackUiMetric={trackUiMetric}
|
||||
multiFields={multiFields?.get(field.name)}
|
||||
onEditField={editField}
|
||||
onDeleteField={deleteField}
|
||||
showFieldStats={showFieldStats}
|
||||
contextualFields={columns}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</EuiAccordion>
|
||||
<EuiSpacer size="s" />{' '}
|
||||
</>
|
||||
) : null}
|
||||
<EuiAccordion
|
||||
id="dscAvailableFields"
|
||||
initialIsOpen={true}
|
||||
buttonContent={
|
||||
<EuiText size="xs" id="available_fields">
|
||||
<strong id={DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}>
|
||||
<FormattedMessage
|
||||
id="discover.fieldChooser.filter.availableFieldsTitle"
|
||||
defaultMessage="Available fields"
|
||||
/>
|
||||
</strong>
|
||||
</EuiText>
|
||||
}
|
||||
extraAction={
|
||||
<EuiNotificationBadge size="m" color={filterChanged ? 'subdued' : 'accent'}>
|
||||
{restFields.length}
|
||||
</EuiNotificationBadge>
|
||||
}
|
||||
>
|
||||
<EuiSpacer size="s" />
|
||||
{!isPlainRecord && popularFields.length > 0 && (
|
||||
<>
|
||||
<EuiTitle size="xxxs" className="dscFieldListHeader">
|
||||
<h4 id="available_fields_popular">
|
||||
<FormattedMessage
|
||||
id="discover.fieldChooser.filter.popularTitle"
|
||||
defaultMessage="Popular"
|
||||
/>
|
||||
</h4>
|
||||
</EuiTitle>
|
||||
<ul
|
||||
className="dscFieldList dscFieldList--popular"
|
||||
aria-labelledby="available_fields available_fields_popular"
|
||||
data-test-subj={`fieldList-popular`}
|
||||
>
|
||||
{popularFields.map((field: DataViewField) => {
|
||||
return (
|
||||
<li key={`field${field.name}`} data-attr-field={field.name}>
|
||||
<DiscoverField
|
||||
alwaysShowActionButton={alwaysShowActionButtons}
|
||||
field={field}
|
||||
dataView={selectedDataView}
|
||||
onAddField={onAddField}
|
||||
onRemoveField={onRemoveField}
|
||||
onAddFilter={onAddFilter}
|
||||
getDetails={getDetailsByField}
|
||||
trackUiMetric={trackUiMetric}
|
||||
multiFields={multiFields?.get(field.name)}
|
||||
onEditField={editField}
|
||||
onDeleteField={deleteField}
|
||||
showFieldStats={showFieldStats}
|
||||
contextualFields={columns}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<ul
|
||||
className="dscFieldList dscFieldList--unpopular"
|
||||
aria-labelledby="available_fields"
|
||||
data-test-subj={`fieldList-unpopular`}
|
||||
ref={availableFieldsContainer}
|
||||
>
|
||||
{getPaginated(restFields).map((field: DataViewField) => {
|
||||
return (
|
||||
<li key={`field${field.name}`} data-attr-field={field.name}>
|
||||
<DiscoverField
|
||||
alwaysShowActionButton={alwaysShowActionButtons}
|
||||
field={field}
|
||||
dataView={selectedDataView}
|
||||
onAddField={onAddField}
|
||||
onRemoveField={onRemoveField}
|
||||
onAddFilter={onAddFilter}
|
||||
getDetails={getDetailsByField}
|
||||
trackUiMetric={trackUiMetric}
|
||||
multiFields={multiFields?.get(field.name)}
|
||||
onEditField={editField}
|
||||
onDeleteField={deleteField}
|
||||
showFieldStats={showFieldStats}
|
||||
contextualFields={columns}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</EuiAccordion>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<EuiFlexItem>
|
||||
{showFieldList && (
|
||||
<FieldListGrouped
|
||||
{...fieldListGroupedProps}
|
||||
renderFieldItem={renderFieldItem}
|
||||
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
{!!editField && (
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -565,3 +416,29 @@ export function DiscoverSidebarComponent({
|
|||
}
|
||||
|
||||
export const DiscoverSidebar = memo(DiscoverSidebarComponent);
|
||||
|
||||
function calculateMultiFields(
|
||||
allFields: DataViewField[] | null,
|
||||
selectedFieldsMap: SelectedFieldsResult['selectedFieldsMap'] | undefined,
|
||||
useNewFieldsApi: boolean
|
||||
) {
|
||||
if (!useNewFieldsApi || !allFields) {
|
||||
return undefined;
|
||||
}
|
||||
const map = new Map<string, Array<{ field: DataViewField; isSelected: boolean }>>();
|
||||
allFields.forEach((field) => {
|
||||
const subTypeMulti = getFieldSubtypeMulti(field);
|
||||
const parent = subTypeMulti?.multi.parent;
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
const multiField = {
|
||||
field,
|
||||
isSelected: Boolean(selectedFieldsMap?.[field.name]),
|
||||
};
|
||||
const value = map.get(parent) ?? [];
|
||||
value.push(multiField);
|
||||
map.set(parent, value);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@
|
|||
import { BehaviorSubject } from 'rxjs';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
|
||||
import { EuiProgress } from '@elastic/eui';
|
||||
import { getDataTableRecords, realHits } from '../../../../__fixtures__/real_hits';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import React from 'react';
|
||||
|
@ -24,12 +25,14 @@ import { AvailableFields$, DataDocuments$, RecordRawType } from '../../hooks/use
|
|||
import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
|
||||
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
|
||||
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
|
||||
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
|
||||
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
|
||||
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list-plugin/public/services/field_existing/load_field_existing';
|
||||
import { resetExistingFieldsCache } from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import type { AggregateQuery, Query } from '@kbn/es-query';
|
||||
import { buildDataTableRecord } from '../../../../utils/build_data_record';
|
||||
import { type DataTableRecord } from '../../../../types';
|
||||
|
||||
jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
|
||||
loadFieldStats: jest.fn().mockResolvedValue({
|
||||
|
@ -67,59 +70,30 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const dataServiceMock = dataPluginMock.createStartContract();
|
||||
|
||||
const mockServices = {
|
||||
history: () => ({
|
||||
location: {
|
||||
search: '',
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
},
|
||||
discover: {
|
||||
save: false,
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: (key: string) => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
}
|
||||
},
|
||||
},
|
||||
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
data: {
|
||||
...dataServiceMock,
|
||||
query: {
|
||||
...dataServiceMock.query,
|
||||
timefilter: {
|
||||
...dataServiceMock.query.timefilter,
|
||||
timefilter: {
|
||||
...dataServiceMock.query.timefilter.timefilter,
|
||||
getAbsoluteTime: () => ({
|
||||
from: '2021-08-31T22:00:00.000Z',
|
||||
to: '2022-09-01T09:16:29.553Z',
|
||||
}),
|
||||
},
|
||||
function createMockServices() {
|
||||
const mockServices = {
|
||||
...createDiscoverServicesMock(),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
},
|
||||
discover: {
|
||||
save: false,
|
||||
},
|
||||
getState: () => ({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
dataViews: dataViewPluginMocks.createStartContract(),
|
||||
fieldFormats: fieldFormatsServiceMock.createStartContract(),
|
||||
charts: chartPluginMock.createSetupContract(),
|
||||
} as unknown as DiscoverServices;
|
||||
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
} as unknown as DiscoverServices;
|
||||
return mockServices;
|
||||
}
|
||||
|
||||
const mockfieldCounts: Record<string, number> = {};
|
||||
const mockCalcFieldCounts = jest.fn(() => {
|
||||
|
@ -138,17 +112,13 @@ jest.mock('../../utils/calc_field_counts', () => ({
|
|||
calcFieldCounts: () => mockCalcFieldCounts(),
|
||||
}));
|
||||
|
||||
function getCompProps(): DiscoverSidebarResponsiveProps {
|
||||
jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting');
|
||||
|
||||
function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarResponsiveProps {
|
||||
const dataView = stubLogstashDataView;
|
||||
dataView.toSpec = jest.fn(() => ({}));
|
||||
|
||||
const hits = getDataTableRecords(dataView);
|
||||
|
||||
const dataViewList = [
|
||||
{ id: '0', title: 'b' } as DataViewListItem,
|
||||
{ id: '1', title: 'a' } as DataViewListItem,
|
||||
{ id: '2', title: 'c' } as DataViewListItem,
|
||||
];
|
||||
const hits = options?.hits ?? getDataTableRecords(dataView);
|
||||
|
||||
for (const hit of hits) {
|
||||
for (const key of Object.keys(hit.flattened)) {
|
||||
|
@ -166,7 +136,7 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
|
|||
fetchStatus: FetchStatus.COMPLETE,
|
||||
fields: [] as string[],
|
||||
}) as AvailableFields$,
|
||||
dataViewList,
|
||||
dataViewList: [dataView as DataViewListItem],
|
||||
onChangeDataView: jest.fn(),
|
||||
onAddFilter: jest.fn(),
|
||||
onAddField: jest.fn(),
|
||||
|
@ -180,52 +150,220 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
|
|||
};
|
||||
}
|
||||
|
||||
function getAppStateContainer({ query }: { query?: Query | AggregateQuery }) {
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
query: query ?? { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
});
|
||||
return appStateContainer;
|
||||
}
|
||||
|
||||
async function mountComponent(
|
||||
props: DiscoverSidebarResponsiveProps,
|
||||
appStateParams: { query?: Query | AggregateQuery } = {},
|
||||
services?: DiscoverServices
|
||||
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
|
||||
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
|
||||
const mockedServices = services ?? createMockServices();
|
||||
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () => props.dataViewList);
|
||||
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
|
||||
return [props.selectedDataView].find((d) => d!.id === id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
comp = await mountWithIntl(
|
||||
<KibanaContextProvider services={mockedServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
|
||||
<DiscoverSidebarResponsive {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
// wait for lazy modules
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await comp.update();
|
||||
});
|
||||
|
||||
await comp!.update();
|
||||
|
||||
return comp!;
|
||||
}
|
||||
|
||||
describe('discover responsive sidebar', function () {
|
||||
let props: DiscoverSidebarResponsiveProps;
|
||||
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => ({
|
||||
indexPatternTitle: 'test',
|
||||
existingFieldNames: Object.keys(mockfieldCounts),
|
||||
}));
|
||||
props = getCompProps();
|
||||
await act(async () => {
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockCalcFieldCounts.mockClear();
|
||||
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear();
|
||||
resetExistingFieldsCache();
|
||||
});
|
||||
|
||||
it('should have loading indicators during fields existence loading', async function () {
|
||||
let resolveFunction: (arg: unknown) => void;
|
||||
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset();
|
||||
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
resolveFunction = resolve;
|
||||
});
|
||||
|
||||
comp = await mountWithIntl(
|
||||
<KibanaContextProvider services={mockServices}>
|
||||
<DiscoverAppStateProvider value={appStateContainer}>
|
||||
<DiscoverSidebarResponsive {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
// wait for lazy modules
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await comp.update();
|
||||
});
|
||||
|
||||
const compLoadingExistence = await mountComponent(props);
|
||||
|
||||
expect(
|
||||
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
|
||||
).toBe(true);
|
||||
expect(
|
||||
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-count').exists()
|
||||
).toBe(false);
|
||||
|
||||
expect(compLoadingExistence.find(EuiProgress).exists()).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveFunction!({
|
||||
indexPatternTitle: 'test-loaded',
|
||||
existingFieldNames: Object.keys(mockfieldCounts),
|
||||
});
|
||||
await compLoadingExistence.update();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await compLoadingExistence.update();
|
||||
});
|
||||
|
||||
expect(
|
||||
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
|
||||
).toBe(false);
|
||||
expect(
|
||||
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-count').exists()
|
||||
).toBe(true);
|
||||
|
||||
expect(compLoadingExistence.find(EuiProgress).exists()).toBe(false);
|
||||
|
||||
expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have Selected Fields and Available Fields with Popular Fields sections', function () {
|
||||
const popular = findTestSubject(comp, 'fieldList-popular');
|
||||
const selected = findTestSubject(comp, 'fieldList-selected');
|
||||
const unpopular = findTestSubject(comp, 'fieldList-unpopular');
|
||||
expect(popular.children().length).toBe(1);
|
||||
expect(unpopular.children().length).toBe(6);
|
||||
expect(selected.children().length).toBe(1);
|
||||
it('should have Selected Fields, Available Fields, Popular and Meta Fields sections', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const popularFieldsCount = findTestSubject(comp, 'fieldListGroupedPopularFields-count');
|
||||
const selectedFieldsCount = findTestSubject(comp, 'fieldListGroupedSelectedFields-count');
|
||||
const availableFieldsCount = findTestSubject(comp, 'fieldListGroupedAvailableFields-count');
|
||||
const emptyFieldsCount = findTestSubject(comp, 'fieldListGroupedEmptyFields-count');
|
||||
const metaFieldsCount = findTestSubject(comp, 'fieldListGroupedMetaFields-count');
|
||||
const unmappedFieldsCount = findTestSubject(comp, 'fieldListGroupedUnmappedFields-count');
|
||||
|
||||
expect(selectedFieldsCount.text()).toBe('1');
|
||||
expect(popularFieldsCount.text()).toBe('4');
|
||||
expect(availableFieldsCount.text()).toBe('3');
|
||||
expect(emptyFieldsCount.text()).toBe('20');
|
||||
expect(metaFieldsCount.text()).toBe('2');
|
||||
expect(unmappedFieldsCount.exists()).toBe(false);
|
||||
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
|
||||
|
||||
expect(props.availableFields$.getValue()).toEqual({
|
||||
fetchStatus: 'complete',
|
||||
fields: ['extension'],
|
||||
});
|
||||
|
||||
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'1 selected field. 4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
|
||||
);
|
||||
|
||||
expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('should allow selecting fields', function () {
|
||||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
|
||||
it('should not have selected fields if no columns selected', async function () {
|
||||
const propsWithoutColumns = {
|
||||
...props,
|
||||
columns: [],
|
||||
};
|
||||
const compWithoutSelected = await mountComponent(propsWithoutColumns);
|
||||
const popularFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedPopularFields-count'
|
||||
);
|
||||
const selectedFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedSelectedFields-count'
|
||||
);
|
||||
const availableFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedAvailableFields-count'
|
||||
);
|
||||
const emptyFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedEmptyFields-count'
|
||||
);
|
||||
const metaFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedMetaFields-count'
|
||||
);
|
||||
const unmappedFieldsCount = findTestSubject(
|
||||
compWithoutSelected,
|
||||
'fieldListGroupedUnmappedFields-count'
|
||||
);
|
||||
|
||||
expect(selectedFieldsCount.exists()).toBe(false);
|
||||
expect(popularFieldsCount.text()).toBe('4');
|
||||
expect(availableFieldsCount.text()).toBe('3');
|
||||
expect(emptyFieldsCount.text()).toBe('20');
|
||||
expect(metaFieldsCount.text()).toBe('2');
|
||||
expect(unmappedFieldsCount.exists()).toBe(false);
|
||||
|
||||
expect(propsWithoutColumns.availableFields$.getValue()).toEqual({
|
||||
fetchStatus: 'complete',
|
||||
fields: ['bytes', 'extension', '_id', 'phpmemory'],
|
||||
});
|
||||
|
||||
expect(findTestSubject(compWithoutSelected, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not calculate counts if documents are not fetched yet', async function () {
|
||||
const propsWithoutDocuments: DiscoverSidebarResponsiveProps = {
|
||||
...props,
|
||||
documents$: new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.UNINITIALIZED,
|
||||
result: undefined,
|
||||
}) as DataDocuments$,
|
||||
};
|
||||
const compWithoutDocuments = await mountComponent(propsWithoutDocuments);
|
||||
const availableFieldsCount = findTestSubject(
|
||||
compWithoutDocuments,
|
||||
'fieldListGroupedAvailableFields-count'
|
||||
);
|
||||
|
||||
expect(availableFieldsCount.exists()).toBe(false);
|
||||
|
||||
expect(mockCalcFieldCounts.mock.calls.length).toBe(0);
|
||||
expect(ExistingFieldsServiceApi.loadFieldExisting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow selecting fields', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
findTestSubject(availableFields, 'fieldToggle-bytes').simulate('click');
|
||||
expect(props.onAddField).toHaveBeenCalledWith('bytes');
|
||||
});
|
||||
it('should allow deselecting fields', function () {
|
||||
findTestSubject(comp, 'fieldToggle-extension').simulate('click');
|
||||
it('should allow deselecting fields', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const selectedFields = findTestSubject(comp, 'fieldListGroupedSelectedFields');
|
||||
findTestSubject(selectedFields, 'fieldToggle-extension').simulate('click');
|
||||
expect(props.onRemoveField).toHaveBeenCalledWith('extension');
|
||||
});
|
||||
it('should allow adding filters', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
const button = findTestSubject(comp, 'field-extension-showDetails');
|
||||
const button = findTestSubject(availableFields, 'field-extension-showDetails');
|
||||
await button.simulate('click');
|
||||
await comp.update();
|
||||
});
|
||||
|
@ -235,8 +373,10 @@ describe('discover responsive sidebar', function () {
|
|||
expect(props.onAddFilter).toHaveBeenCalled();
|
||||
});
|
||||
it('should allow adding "exist" filter', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
const button = findTestSubject(comp, 'field-extension-showDetails');
|
||||
const button = findTestSubject(availableFields, 'field-extension-showDetails');
|
||||
await button.simulate('click');
|
||||
await comp.update();
|
||||
});
|
||||
|
@ -245,27 +385,38 @@ describe('discover responsive sidebar', function () {
|
|||
findTestSubject(comp, 'discoverFieldListPanelAddExistFilter-extension').simulate('click');
|
||||
expect(props.onAddFilter).toHaveBeenCalledWith('_exists_', 'extension', '+');
|
||||
});
|
||||
it('should allow filtering by string, and calcFieldCount should just be executed once', function () {
|
||||
expect(findTestSubject(comp, 'fieldList-unpopular').children().length).toBe(6);
|
||||
act(() => {
|
||||
findTestSubject(comp, 'fieldFilterSearchInput').simulate('change', {
|
||||
target: { value: 'abc' },
|
||||
it('should allow filtering by string, and calcFieldCount should just be executed once', async function () {
|
||||
const comp = await mountComponent(props);
|
||||
|
||||
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('3');
|
||||
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'1 selected field. 4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await findTestSubject(comp, 'fieldFilterSearchInput').simulate('change', {
|
||||
target: { value: 'bytes' },
|
||||
});
|
||||
});
|
||||
comp.update();
|
||||
expect(findTestSubject(comp, 'fieldList-unpopular').children().length).toBe(4);
|
||||
|
||||
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('1');
|
||||
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'1 popular field. 1 available field. 0 empty fields. 0 meta fields.'
|
||||
);
|
||||
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should show "Add a field" button to create a runtime field', () => {
|
||||
expect(mockServices.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
|
||||
it('should show "Add a field" button to create a runtime field', async () => {
|
||||
const services = createMockServices();
|
||||
const comp = await mountComponent(props, {}, services);
|
||||
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
|
||||
expect(findTestSubject(comp, 'dataView-add-field_btn').length).toBe(1);
|
||||
});
|
||||
|
||||
it('should not show "Add a field" button on the sql mode', () => {
|
||||
const initialProps = getCompProps();
|
||||
it('should render correctly in the sql mode', async () => {
|
||||
const propsWithTextBasedMode = {
|
||||
...initialProps,
|
||||
...props,
|
||||
columns: ['extension', 'bytes'],
|
||||
onAddFilter: undefined,
|
||||
documents$: new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
|
@ -273,46 +424,75 @@ describe('discover responsive sidebar', function () {
|
|||
result: getDataTableRecords(stubLogstashDataView),
|
||||
}) as DataDocuments$,
|
||||
};
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
const compInViewerMode = await mountComponent(propsWithTextBasedMode, {
|
||||
query: { sql: 'SELECT * FROM `index`' },
|
||||
});
|
||||
const compInViewerMode = mountWithIntl(
|
||||
<KibanaContextProvider services={mockServices}>
|
||||
<DiscoverAppStateProvider value={appStateContainer}>
|
||||
<DiscoverSidebarResponsive {...propsWithTextBasedMode} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0);
|
||||
|
||||
const popularFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
'fieldListGroupedPopularFields-count'
|
||||
);
|
||||
const selectedFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
'fieldListGroupedSelectedFields-count'
|
||||
);
|
||||
const availableFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
'fieldListGroupedAvailableFields-count'
|
||||
);
|
||||
const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count');
|
||||
const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count');
|
||||
const unmappedFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
'fieldListGroupedUnmappedFields-count'
|
||||
);
|
||||
|
||||
expect(selectedFieldsCount.text()).toBe('2');
|
||||
expect(popularFieldsCount.exists()).toBe(false);
|
||||
expect(availableFieldsCount.text()).toBe('4');
|
||||
expect(emptyFieldsCount.exists()).toBe(false);
|
||||
expect(metaFieldsCount.exists()).toBe(false);
|
||||
expect(unmappedFieldsCount.exists()).toBe(false);
|
||||
|
||||
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
|
||||
|
||||
expect(findTestSubject(compInViewerMode, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'2 selected fields. 4 available fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show "Add a field" button in viewer mode', () => {
|
||||
const mockedServicesInViewerMode = {
|
||||
...mockServices,
|
||||
dataViewEditor: {
|
||||
...mockServices.dataViewEditor,
|
||||
userPermissions: {
|
||||
...mockServices.dataViewEditor.userPermissions,
|
||||
editDataView: jest.fn(() => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
|
||||
appStateContainer.set({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
it('should render correctly unmapped fields', async () => {
|
||||
const propsWithUnmappedField = getCompProps({
|
||||
hits: [
|
||||
buildDataTableRecord(realHits[0], stubLogstashDataView),
|
||||
buildDataTableRecord(
|
||||
{
|
||||
_index: 'logstash-2014.09.09',
|
||||
_id: '1945',
|
||||
_score: 1,
|
||||
_source: {
|
||||
extension: 'gif',
|
||||
bytes: 10617.2,
|
||||
test_unmapped: 'show me too',
|
||||
},
|
||||
},
|
||||
stubLogstashDataView
|
||||
),
|
||||
],
|
||||
});
|
||||
const compInViewerMode = mountWithIntl(
|
||||
<KibanaContextProvider services={mockedServicesInViewerMode}>
|
||||
<DiscoverAppStateProvider value={appStateContainer}>
|
||||
<DiscoverSidebarResponsive {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
const compWithUnmapped = await mountComponent(propsWithUnmappedField);
|
||||
|
||||
expect(findTestSubject(compWithUnmapped, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'1 selected field. 4 popular fields. 3 available fields. 1 unmapped field. 20 empty fields. 2 meta fields.'
|
||||
);
|
||||
expect(
|
||||
mockedServicesInViewerMode.dataViewEditor.userPermissions.editDataView
|
||||
).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show "Add a field" button in viewer mode', async () => {
|
||||
const services = createMockServices();
|
||||
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
|
||||
const compInViewerMode = await mountComponent(props, {}, services);
|
||||
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
|
||||
expect(findTestSubject(compInViewerMode, 'dataView-add-field_btn').length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
|
@ -19,20 +19,31 @@ import {
|
|||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiPortal,
|
||||
EuiProgress,
|
||||
EuiShowFor,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import type { DataView, DataViewField, DataViewListItem } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
useExistingFieldsFetcher,
|
||||
useQuerySubscriber,
|
||||
} from '@kbn/unified-field-list-plugin/public';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { getDefaultFieldFilter } from './lib/field_filter';
|
||||
import { DiscoverSidebar } from './discover_sidebar';
|
||||
import { AvailableFields$, DataDocuments$, RecordRawType } from '../../hooks/use_saved_search';
|
||||
import { calcFieldCounts } from '../../utils/calc_field_counts';
|
||||
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
|
||||
import { getRawRecordType } from '../../utils/get_raw_record_type';
|
||||
import { useAppStateSelector } from '../../services/discover_app_state_container';
|
||||
import {
|
||||
discoverSidebarReducer,
|
||||
getInitialState,
|
||||
DiscoverSidebarReducerActionType,
|
||||
DiscoverSidebarReducerStatus,
|
||||
} from './lib/sidebar_reducer';
|
||||
import { calcFieldCounts } from '../../utils/calc_field_counts';
|
||||
|
||||
export interface DiscoverSidebarResponsiveProps {
|
||||
/**
|
||||
|
@ -111,38 +122,94 @@ export interface DiscoverSidebarResponsiveProps {
|
|||
*/
|
||||
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
|
||||
const services = useDiscoverServices();
|
||||
const { data, dataViews, core } = services;
|
||||
const isPlainRecord = useAppStateSelector(
|
||||
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
|
||||
);
|
||||
const { selectedDataView, onFieldEdited, onDataViewCreated } = props;
|
||||
const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter());
|
||||
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
|
||||
/**
|
||||
* fieldCounts are used to determine which fields are actually used in the given set of documents
|
||||
*/
|
||||
const fieldCounts = useRef<Record<string, number> | null>(null);
|
||||
if (fieldCounts.current === null) {
|
||||
fieldCounts.current = calcFieldCounts(props.documents$.getValue().result!, selectedDataView);
|
||||
}
|
||||
const [sidebarState, dispatchSidebarStateAction] = useReducer(
|
||||
discoverSidebarReducer,
|
||||
selectedDataView,
|
||||
getInitialState
|
||||
);
|
||||
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
|
||||
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
|
||||
|
||||
const [documentState, setDocumentState] = useState(props.documents$.getValue());
|
||||
useEffect(() => {
|
||||
const subscription = props.documents$.subscribe((next) => {
|
||||
if (next.fetchStatus !== documentState.fetchStatus) {
|
||||
if (next.result) {
|
||||
fieldCounts.current = calcFieldCounts(next.result, selectedDataView!);
|
||||
}
|
||||
setDocumentState({ ...documentState, ...next });
|
||||
const subscription = props.documents$.subscribe((documentState) => {
|
||||
const isPlainRecordType = documentState.recordRawType === RecordRawType.PLAIN;
|
||||
|
||||
switch (documentState?.fetchStatus) {
|
||||
case FetchStatus.UNINITIALIZED:
|
||||
dispatchSidebarStateAction({
|
||||
type: DiscoverSidebarReducerActionType.RESET,
|
||||
payload: {
|
||||
dataView: selectedDataViewRef.current,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case FetchStatus.LOADING:
|
||||
dispatchSidebarStateAction({
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING,
|
||||
payload: {
|
||||
isPlainRecord: isPlainRecordType,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case FetchStatus.COMPLETE:
|
||||
dispatchSidebarStateAction({
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED,
|
||||
payload: {
|
||||
dataView: selectedDataViewRef.current,
|
||||
fieldCounts: calcFieldCounts(documentState.result),
|
||||
isPlainRecord: isPlainRecordType,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [props.documents$, selectedDataView, documentState, setDocumentState]);
|
||||
}, [props.documents$, dispatchSidebarStateAction, selectedDataViewRef]);
|
||||
|
||||
useEffect(() => {
|
||||
// when data view changes fieldCounts needs to be cleaned up to prevent displaying
|
||||
// fields of the previous data view
|
||||
fieldCounts.current = {};
|
||||
}, [selectedDataView]);
|
||||
if (selectedDataView !== selectedDataViewRef.current) {
|
||||
dispatchSidebarStateAction({
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
|
||||
payload: {
|
||||
dataView: selectedDataView,
|
||||
},
|
||||
});
|
||||
selectedDataViewRef.current = selectedDataView;
|
||||
}
|
||||
}, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]);
|
||||
|
||||
const querySubscriberResult = useQuerySubscriber({ data });
|
||||
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
|
||||
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
|
||||
disableAutoFetching: true,
|
||||
dataViews: !isPlainRecord && sidebarState.dataView ? [sidebarState.dataView] : [],
|
||||
query: querySubscriberResult.query,
|
||||
filters: querySubscriberResult.filters,
|
||||
fromDate: querySubscriberResult.fromDate,
|
||||
toDate: querySubscriberResult.toDate,
|
||||
services: {
|
||||
data,
|
||||
dataViews,
|
||||
core,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) {
|
||||
refetchFieldsExistenceInfo();
|
||||
}
|
||||
// refetching only if status changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sidebarState.status]);
|
||||
|
||||
const closeFieldEditor = useRef<() => void | undefined>();
|
||||
const closeDataViewEditor = useRef<() => void | undefined>();
|
||||
|
@ -180,30 +247,18 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
const canEditDataView =
|
||||
Boolean(dataViewEditor?.userPermissions.editDataView()) || !selectedDataView?.isPersisted();
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
// For an external embeddable like the Field stats
|
||||
// it is useful to know what fields are populated in the docs fetched
|
||||
// or what fields are selected by the user
|
||||
useEffect(() => {
|
||||
// For an external embeddable like the Field stats
|
||||
// it is useful to know what fields are populated in the docs fetched
|
||||
// or what fields are selected by the user
|
||||
|
||||
const fieldCnts = fieldCounts.current ?? {};
|
||||
|
||||
const availableFields = props.columns.length > 0 ? props.columns : Object.keys(fieldCnts);
|
||||
availableFields$.next({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
fields: availableFields,
|
||||
});
|
||||
},
|
||||
// Using columns.length here instead of columns to avoid array reference changing
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
selectedDataView,
|
||||
availableFields$,
|
||||
fieldCounts.current,
|
||||
documentState.result,
|
||||
props.columns.length,
|
||||
]
|
||||
);
|
||||
const availableFields =
|
||||
props.columns.length > 0 ? props.columns : Object.keys(sidebarState.fieldCounts || {});
|
||||
availableFields$.next({
|
||||
fetchStatus: FetchStatus.COMPLETE,
|
||||
fields: availableFields,
|
||||
});
|
||||
}, [selectedDataView, sidebarState.fieldCounts, props.columns, availableFields$]);
|
||||
|
||||
const editField = useMemo(
|
||||
() =>
|
||||
|
@ -259,14 +314,17 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
<>
|
||||
{!props.isClosed && (
|
||||
<EuiHideFor sizes={['xs', 's']}>
|
||||
{isProcessing && <EuiProgress size="xs" color="accent" position="absolute" />}
|
||||
<DiscoverSidebar
|
||||
{...props}
|
||||
documents={documentState.result!}
|
||||
onFieldEdited={onFieldEdited}
|
||||
allFields={sidebarState.allFields}
|
||||
fieldFilter={fieldFilter}
|
||||
fieldCounts={fieldCounts.current}
|
||||
setFieldFilter={setFieldFilter}
|
||||
editField={editField}
|
||||
createNewDataView={createNewDataView}
|
||||
showFieldList={showFieldList}
|
||||
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
|
||||
/>
|
||||
</EuiHideFor>
|
||||
)}
|
||||
|
@ -322,8 +380,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
</EuiFlyoutHeader>
|
||||
<DiscoverSidebar
|
||||
{...props}
|
||||
documents={documentState.result}
|
||||
fieldCounts={fieldCounts.current}
|
||||
onFieldEdited={onFieldEdited}
|
||||
allFields={sidebarState.allFields}
|
||||
fieldFilter={fieldFilter}
|
||||
setFieldFilter={setFieldFilter}
|
||||
alwaysShowActionButtons={true}
|
||||
|
@ -332,6 +390,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
editField={editField}
|
||||
createNewDataView={createNewDataView}
|
||||
showDataViewPicker={true}
|
||||
showFieldList={showFieldList}
|
||||
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
|
||||
/>
|
||||
</EuiFlyout>
|
||||
</EuiPortal>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { getDefaultFieldFilter, setFieldFilterProp, isFieldFiltered } from './field_filter';
|
||||
import { getDefaultFieldFilter, setFieldFilterProp, doesFieldMatchFilters } from './field_filter';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
|
||||
describe('field_filter', function () {
|
||||
|
@ -14,7 +14,6 @@ describe('field_filter', function () {
|
|||
expect(getDefaultFieldFilter()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"aggregatable": null,
|
||||
"missing": true,
|
||||
"name": "",
|
||||
"searchable": null,
|
||||
"type": "any",
|
||||
|
@ -25,7 +24,6 @@ describe('field_filter', function () {
|
|||
const state = getDefaultFieldFilter();
|
||||
const targetState = {
|
||||
aggregatable: true,
|
||||
missing: true,
|
||||
name: 'test',
|
||||
searchable: true,
|
||||
type: 'string',
|
||||
|
@ -36,7 +34,6 @@ describe('field_filter', function () {
|
|||
expect(actualState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"aggregatable": true,
|
||||
"missing": true,
|
||||
"name": "test",
|
||||
"searchable": true,
|
||||
"type": "string",
|
||||
|
@ -78,9 +75,7 @@ describe('field_filter', function () {
|
|||
{ filter: { type: 'string' }, result: ['extension'] },
|
||||
].forEach((test) => {
|
||||
const filtered = fieldList
|
||||
.filter((field) =>
|
||||
isFieldFiltered(field, { ...defaultState, ...test.filter }, { bytes: 1, extension: 1 })
|
||||
)
|
||||
.filter((field) => doesFieldMatchFilters(field, { ...defaultState, ...test.filter }))
|
||||
.map((field) => field.name);
|
||||
|
||||
expect(filtered).toEqual(test.result);
|
||||
|
|
|
@ -9,7 +9,6 @@
|
|||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
|
||||
export interface FieldFilterState {
|
||||
missing: boolean;
|
||||
type: string;
|
||||
name: string;
|
||||
aggregatable: null | boolean;
|
||||
|
@ -18,7 +17,6 @@ export interface FieldFilterState {
|
|||
|
||||
export function getDefaultFieldFilter(): FieldFilterState {
|
||||
return {
|
||||
missing: true,
|
||||
type: 'any',
|
||||
name: '',
|
||||
aggregatable: null,
|
||||
|
@ -32,9 +30,7 @@ export function setFieldFilterProp(
|
|||
value: string | boolean | null | undefined
|
||||
): FieldFilterState {
|
||||
const newState = { ...state };
|
||||
if (name === 'missing') {
|
||||
newState.missing = Boolean(value);
|
||||
} else if (name === 'aggregatable') {
|
||||
if (name === 'aggregatable') {
|
||||
newState.aggregatable = typeof value !== 'boolean' ? null : value;
|
||||
} else if (name === 'searchable') {
|
||||
newState.searchable = typeof value !== 'boolean' ? null : value;
|
||||
|
@ -46,25 +42,18 @@ export function setFieldFilterProp(
|
|||
return newState;
|
||||
}
|
||||
|
||||
export function isFieldFiltered(
|
||||
export function doesFieldMatchFilters(
|
||||
field: DataViewField,
|
||||
filterState: FieldFilterState,
|
||||
fieldCounts: Record<string, number>
|
||||
filterState: FieldFilterState
|
||||
): boolean {
|
||||
const matchFilter = filterState.type === 'any' || field.type === filterState.type;
|
||||
const isAggregatable =
|
||||
filterState.aggregatable === null || field.aggregatable === filterState.aggregatable;
|
||||
const isSearchable =
|
||||
filterState.searchable === null || field.searchable === filterState.searchable;
|
||||
const scriptedOrMissing =
|
||||
!filterState.missing ||
|
||||
field.type === '_source' ||
|
||||
field.type === 'unknown_selected' ||
|
||||
field.scripted ||
|
||||
fieldCounts[field.name] > 0;
|
||||
const needle = filterState.name ? filterState.name.toLowerCase() : '';
|
||||
const haystack = `${field.name}${field.displayName || ''}`.toLowerCase();
|
||||
const matchName = !filterState.name || haystack.indexOf(needle) !== -1;
|
||||
|
||||
return matchFilter && isAggregatable && isSearchable && scriptedOrMissing && matchName;
|
||||
return matchFilter && isAggregatable && isSearchable && matchName;
|
||||
}
|
||||
|
|
|
@ -7,18 +7,37 @@
|
|||
*/
|
||||
|
||||
import { difference } from 'lodash';
|
||||
import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { fieldWildcardFilter } from '@kbn/kibana-utils-plugin/public';
|
||||
import { isNestedFieldParent } from '../../../utils/nested_fields';
|
||||
|
||||
export function getDataViewFieldList(dataView?: DataView, fieldCounts?: Record<string, number>) {
|
||||
if (!dataView || !fieldCounts) return [];
|
||||
export function getDataViewFieldList(
|
||||
dataView: DataView | undefined | null,
|
||||
fieldCounts: Record<string, number> | undefined | null,
|
||||
isPlainRecord: boolean
|
||||
): DataViewField[] | null {
|
||||
if (isPlainRecord && !fieldCounts) {
|
||||
// still loading data
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldNamesInDocs = Object.keys(fieldCounts);
|
||||
const fieldNamesInDataView = dataView.fields.getAll().map((fld) => fld.name);
|
||||
const currentFieldCounts = fieldCounts || {};
|
||||
const sourceFiltersValues = dataView?.getSourceFiltering?.()?.excludes;
|
||||
let dataViewFields: DataViewField[] = dataView?.fields.getAll() || [];
|
||||
|
||||
if (sourceFiltersValues) {
|
||||
const filter = fieldWildcardFilter(sourceFiltersValues, dataView.metaFields);
|
||||
dataViewFields = dataViewFields.filter((field) => {
|
||||
return filter(field.name) || currentFieldCounts[field.name]; // don't filter out a field which was present in hits (ex. for text-based queries, selected fields)
|
||||
});
|
||||
}
|
||||
|
||||
const fieldNamesInDocs = Object.keys(currentFieldCounts);
|
||||
const fieldNamesInDataView = dataViewFields.map((fld) => fld.name);
|
||||
const unknownFields: DataViewField[] = [];
|
||||
|
||||
difference(fieldNamesInDocs, fieldNamesInDataView).forEach((unknownFieldName) => {
|
||||
if (isNestedFieldParent(unknownFieldName, dataView)) {
|
||||
if (dataView && isNestedFieldParent(unknownFieldName, dataView)) {
|
||||
unknownFields.push({
|
||||
displayName: String(unknownFieldName),
|
||||
name: String(unknownFieldName),
|
||||
|
@ -33,5 +52,10 @@ export function getDataViewFieldList(dataView?: DataView, fieldCounts?: Record<s
|
|||
}
|
||||
});
|
||||
|
||||
return [...dataView.fields.getAll(), ...unknownFields];
|
||||
return [
|
||||
...(isPlainRecord
|
||||
? dataViewFields.filter((field) => currentFieldCounts[field.name])
|
||||
: dataViewFields),
|
||||
...unknownFields,
|
||||
];
|
||||
}
|
||||
|
|
|
@ -6,271 +6,106 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { groupFields } from './group_fields';
|
||||
import { getDefaultFieldFilter } from './field_filter';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: 'category',
|
||||
type: 'string',
|
||||
esTypes: ['text'],
|
||||
count: 1,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
{
|
||||
name: 'customer_birth_date',
|
||||
type: 'date',
|
||||
esTypes: ['date'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
},
|
||||
];
|
||||
|
||||
const fieldCounts = {
|
||||
category: 1,
|
||||
currency: 1,
|
||||
customer_birth_date: 1,
|
||||
unknown_field: 1,
|
||||
};
|
||||
import { type DataViewField } from '@kbn/data-plugin/common';
|
||||
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { getSelectedFields, shouldShowField, INITIAL_SELECTED_FIELDS_RESULT } from './group_fields';
|
||||
|
||||
describe('group_fields', function () {
|
||||
it('should group fields in selected, popular, unpopular group', function () {
|
||||
const fieldFilterState = getDefaultFieldFilter();
|
||||
|
||||
const actual = groupFields(
|
||||
fields as DataViewField[],
|
||||
['currency'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
false
|
||||
);
|
||||
it('should pick fields as unknown_selected if they are unknown', function () {
|
||||
const actual = getSelectedFields(dataView, ['currency']);
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"popular": Array [
|
||||
"selectedFields": Array [
|
||||
Object {
|
||||
"aggregatable": true,
|
||||
"count": 1,
|
||||
"esTypes": Array [
|
||||
"text",
|
||||
],
|
||||
"name": "category",
|
||||
"readFromDocValues": true,
|
||||
"scripted": false,
|
||||
"searchable": true,
|
||||
"type": "string",
|
||||
},
|
||||
],
|
||||
"selected": Array [
|
||||
Object {
|
||||
"aggregatable": true,
|
||||
"count": 0,
|
||||
"esTypes": Array [
|
||||
"keyword",
|
||||
],
|
||||
"displayName": "currency",
|
||||
"name": "currency",
|
||||
"readFromDocValues": true,
|
||||
"scripted": false,
|
||||
"searchable": true,
|
||||
"type": "string",
|
||||
},
|
||||
],
|
||||
"unpopular": Array [
|
||||
Object {
|
||||
"aggregatable": true,
|
||||
"count": 0,
|
||||
"esTypes": Array [
|
||||
"date",
|
||||
],
|
||||
"name": "customer_birth_date",
|
||||
"readFromDocValues": true,
|
||||
"scripted": false,
|
||||
"searchable": true,
|
||||
"type": "date",
|
||||
"type": "unknown_selected",
|
||||
},
|
||||
],
|
||||
"selectedFieldsMap": Object {
|
||||
"currency": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
it('should group fields in selected, popular, unpopular group if they contain multifields', function () {
|
||||
const category = {
|
||||
name: 'category',
|
||||
type: 'string',
|
||||
esTypes: ['text'],
|
||||
count: 1,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
};
|
||||
const currency = {
|
||||
name: 'currency',
|
||||
displayName: 'currency',
|
||||
kbnFieldType: {
|
||||
esTypes: ['string', 'text', 'keyword', '_type', '_id'],
|
||||
filterable: true,
|
||||
name: 'string',
|
||||
sortable: true,
|
||||
},
|
||||
spec: {
|
||||
esTypes: ['text'],
|
||||
name: 'category',
|
||||
},
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
};
|
||||
const currencyKeyword = {
|
||||
name: 'currency.keyword',
|
||||
displayName: 'currency.keyword',
|
||||
type: 'string',
|
||||
esTypes: ['keyword'],
|
||||
kbnFieldType: {
|
||||
esTypes: ['string', 'text', 'keyword', '_type', '_id'],
|
||||
filterable: true,
|
||||
name: 'string',
|
||||
sortable: true,
|
||||
},
|
||||
spec: {
|
||||
aggregatable: true,
|
||||
esTypes: ['keyword'],
|
||||
name: 'category.keyword',
|
||||
readFromDocValues: true,
|
||||
searchable: true,
|
||||
shortDotsEnable: false,
|
||||
subType: {
|
||||
multi: {
|
||||
parent: 'currency',
|
||||
},
|
||||
},
|
||||
},
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: false,
|
||||
};
|
||||
const fieldsToGroup = [category, currency, currencyKeyword] as DataViewField[];
|
||||
|
||||
const fieldFilterState = getDefaultFieldFilter();
|
||||
|
||||
const actual = groupFields(fieldsToGroup, ['currency'], 5, fieldCounts, fieldFilterState, true);
|
||||
|
||||
expect(actual.popular).toEqual([category]);
|
||||
expect(actual.selected).toEqual([currency]);
|
||||
expect(actual.unpopular).toEqual([]);
|
||||
it('should work correctly if no columns selected', function () {
|
||||
expect(getSelectedFields(dataView, [])).toBe(INITIAL_SELECTED_FIELDS_RESULT);
|
||||
expect(getSelectedFields(dataView, ['_source'])).toBe(INITIAL_SELECTED_FIELDS_RESULT);
|
||||
});
|
||||
|
||||
it('should sort selected fields by columns order ', function () {
|
||||
const fieldFilterState = getDefaultFieldFilter();
|
||||
it('should pick fields into selected group', function () {
|
||||
const actual = getSelectedFields(dataView, ['bytes', '@timestamp']);
|
||||
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
|
||||
expect(actual.selectedFieldsMap).toStrictEqual({
|
||||
bytes: true,
|
||||
'@timestamp': true,
|
||||
});
|
||||
});
|
||||
|
||||
const actual1 = groupFields(
|
||||
fields as DataViewField[],
|
||||
['customer_birth_date', 'currency', 'unknown'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
false
|
||||
);
|
||||
expect(actual1.selected.map((field) => field.name)).toEqual([
|
||||
'customer_birth_date',
|
||||
'currency',
|
||||
it('should pick fields into selected group if they contain multifields', function () {
|
||||
const actual = getSelectedFields(dataView, ['machine.os', 'machine.os.raw']);
|
||||
expect(actual.selectedFields.map((field) => field.name)).toEqual([
|
||||
'machine.os',
|
||||
'machine.os.raw',
|
||||
]);
|
||||
expect(actual.selectedFieldsMap).toStrictEqual({
|
||||
'machine.os': true,
|
||||
'machine.os.raw': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sort selected fields by columns order', function () {
|
||||
const actual1 = getSelectedFields(dataView, ['bytes', 'extension.keyword', 'unknown']);
|
||||
expect(actual1.selectedFields.map((field) => field.name)).toEqual([
|
||||
'bytes',
|
||||
'extension.keyword',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
const actual2 = groupFields(
|
||||
fields as DataViewField[],
|
||||
['currency', 'customer_birth_date', 'unknown'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
false
|
||||
);
|
||||
expect(actual2.selected.map((field) => field.name)).toEqual([
|
||||
'currency',
|
||||
'customer_birth_date',
|
||||
'unknown',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should filter fields by a given name', function () {
|
||||
const fieldFilterState = { ...getDefaultFieldFilter(), ...{ name: 'curr' } };
|
||||
|
||||
const actual1 = groupFields(
|
||||
fields as DataViewField[],
|
||||
['customer_birth_date', 'currency', 'unknown'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
false
|
||||
);
|
||||
expect(actual1.selected.map((field) => field.name)).toEqual(['currency']);
|
||||
});
|
||||
|
||||
it('excludes unmapped fields if showUnmappedFields set to false', function () {
|
||||
const fieldFilterState = getDefaultFieldFilter();
|
||||
const fieldsWithUnmappedField = [...fields];
|
||||
fieldsWithUnmappedField.push({
|
||||
name: 'unknown_field',
|
||||
type: 'unknown',
|
||||
esTypes: ['unknown'],
|
||||
count: 1,
|
||||
scripted: false,
|
||||
searchable: true,
|
||||
aggregatable: true,
|
||||
readFromDocValues: true,
|
||||
expect(actual1.selectedFieldsMap).toStrictEqual({
|
||||
bytes: true,
|
||||
'extension.keyword': true,
|
||||
unknown: true,
|
||||
});
|
||||
|
||||
const actual = groupFields(
|
||||
fieldsWithUnmappedField as DataViewField[],
|
||||
['customer_birth_date', 'currency'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
const actual2 = getSelectedFields(dataView, ['extension', 'bytes', 'unknown']);
|
||||
expect(actual2.selectedFields.map((field) => field.name)).toEqual([
|
||||
'extension',
|
||||
'bytes',
|
||||
'unknown',
|
||||
]);
|
||||
expect(actual2.selectedFieldsMap).toStrictEqual({
|
||||
extension: true,
|
||||
bytes: true,
|
||||
unknown: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show any fields if for text-based searches', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('bytes'), true)).toBe(true);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, true)).toBe(true);
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should show fields excluding subfields when searched from source', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(actual.unpopular).toEqual([]);
|
||||
});
|
||||
|
||||
it('includes unmapped fields when reading from source', function () {
|
||||
const fieldFilterState = getDefaultFieldFilter();
|
||||
const fieldsWithUnmappedField = [...fields];
|
||||
fieldsWithUnmappedField.push({
|
||||
name: 'unknown_field',
|
||||
type: 'unknown',
|
||||
esTypes: ['unknown'],
|
||||
count: 0,
|
||||
scripted: false,
|
||||
searchable: false,
|
||||
aggregatable: false,
|
||||
readFromDocValues: false,
|
||||
});
|
||||
|
||||
const actual = groupFields(
|
||||
fieldsWithUnmappedField as DataViewField[],
|
||||
['customer_birth_date', 'currency'],
|
||||
5,
|
||||
fieldCounts,
|
||||
fieldFilterState,
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should show fields excluding subfields when fields api is used', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
|
||||
false
|
||||
);
|
||||
expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,90 +6,66 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
|
||||
import { FieldFilterState, isFieldFiltered } from './field_filter';
|
||||
import { uniqBy } from 'lodash';
|
||||
import {
|
||||
type DataViewField,
|
||||
type DataView,
|
||||
getFieldSubtypeMulti,
|
||||
} from '@kbn/data-views-plugin/public';
|
||||
|
||||
interface GroupedFields {
|
||||
selected: DataViewField[];
|
||||
popular: DataViewField[];
|
||||
unpopular: DataViewField[];
|
||||
export function shouldShowField(field: DataViewField | undefined, isPlainRecord: boolean): boolean {
|
||||
if (!field?.type || field.type === '_source') {
|
||||
return false;
|
||||
}
|
||||
if (isPlainRecord) {
|
||||
// exclude only `_source` for plain records
|
||||
return true;
|
||||
}
|
||||
// exclude subfields
|
||||
return !getFieldSubtypeMulti(field?.spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* group the fields into selected, popular and unpopular, filter by fieldFilterState
|
||||
*/
|
||||
export function groupFields(
|
||||
fields: DataViewField[] | null,
|
||||
columns: string[],
|
||||
popularLimit: number,
|
||||
fieldCounts: Record<string, number> | undefined,
|
||||
fieldFilterState: FieldFilterState,
|
||||
useNewFieldsApi: boolean
|
||||
): GroupedFields {
|
||||
const showUnmappedFields = useNewFieldsApi;
|
||||
const result: GroupedFields = {
|
||||
selected: [],
|
||||
popular: [],
|
||||
unpopular: [],
|
||||
// to avoid rerenderings for empty state
|
||||
export const INITIAL_SELECTED_FIELDS_RESULT = {
|
||||
selectedFields: [],
|
||||
selectedFieldsMap: {},
|
||||
};
|
||||
|
||||
export interface SelectedFieldsResult {
|
||||
selectedFields: DataViewField[];
|
||||
selectedFieldsMap: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function getSelectedFields(
|
||||
dataView: DataView | undefined,
|
||||
columns: string[]
|
||||
): SelectedFieldsResult {
|
||||
const result: SelectedFieldsResult = {
|
||||
selectedFields: [],
|
||||
selectedFieldsMap: {},
|
||||
};
|
||||
if (!Array.isArray(fields) || !Array.isArray(columns) || typeof fieldCounts !== 'object') {
|
||||
return result;
|
||||
if (!Array.isArray(columns) || !columns.length) {
|
||||
return INITIAL_SELECTED_FIELDS_RESULT;
|
||||
}
|
||||
|
||||
const popular = fields
|
||||
.filter((field) => !columns.includes(field.name) && field.count)
|
||||
.sort((a: DataViewField, b: DataViewField) => (b.count || 0) - (a.count || 0))
|
||||
.map((field) => field.name)
|
||||
.slice(0, popularLimit);
|
||||
|
||||
const compareFn = (a: DataViewField, b: DataViewField) => {
|
||||
if (!a.displayName) {
|
||||
return 0;
|
||||
}
|
||||
return a.displayName.localeCompare(b.displayName || '');
|
||||
};
|
||||
const fieldsSorted = fields.sort(compareFn);
|
||||
|
||||
for (const field of fieldsSorted) {
|
||||
if (!isFieldFiltered(field, fieldFilterState, fieldCounts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subTypeMulti = getFieldSubtypeMulti(field?.spec);
|
||||
const isSubfield = useNewFieldsApi && subTypeMulti;
|
||||
if (columns.includes(field.name)) {
|
||||
result.selected.push(field);
|
||||
} else if (popular.includes(field.name) && field.type !== '_source') {
|
||||
if (!isSubfield) {
|
||||
result.popular.push(field);
|
||||
}
|
||||
} else if (field.type !== '_source') {
|
||||
// do not show unmapped fields unless explicitly specified
|
||||
// do not add subfields to this list
|
||||
if (useNewFieldsApi && (field.type !== 'unknown' || showUnmappedFields) && !isSubfield) {
|
||||
result.unpopular.push(field);
|
||||
} else if (!useNewFieldsApi) {
|
||||
result.unpopular.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
// add selected columns, that are not part of the data view, to be removable
|
||||
for (const column of columns) {
|
||||
const tmpField = {
|
||||
name: column,
|
||||
displayName: column,
|
||||
type: 'unknown_selected',
|
||||
} as DataViewField;
|
||||
if (
|
||||
!result.selected.find((field) => field.name === column) &&
|
||||
isFieldFiltered(tmpField, fieldFilterState, fieldCounts)
|
||||
) {
|
||||
result.selected.push(tmpField);
|
||||
}
|
||||
const selectedField =
|
||||
dataView?.getFieldByName?.(column) ||
|
||||
({
|
||||
name: column,
|
||||
displayName: column,
|
||||
type: 'unknown_selected',
|
||||
} as DataViewField);
|
||||
result.selectedFields.push(selectedField);
|
||||
result.selectedFieldsMap[selectedField.name] = true;
|
||||
}
|
||||
|
||||
result.selectedFields = uniqBy(result.selectedFields, 'name');
|
||||
|
||||
if (result.selectedFields.length === 1 && result.selectedFields[0].name === '_source') {
|
||||
return INITIAL_SELECTED_FIELDS_RESULT;
|
||||
}
|
||||
result.selected.sort((a, b) => {
|
||||
return columns.indexOf(a.name) - columns.indexOf(b.name);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
stubDataViewWithoutTimeField,
|
||||
stubLogstashDataView as dataView,
|
||||
} from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import {
|
||||
discoverSidebarReducer,
|
||||
DiscoverSidebarReducerActionType,
|
||||
DiscoverSidebarReducerState,
|
||||
DiscoverSidebarReducerStatus,
|
||||
getInitialState,
|
||||
} from './sidebar_reducer';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
|
||||
describe('sidebar reducer', function () {
|
||||
it('should set an initial state', function () {
|
||||
expect(getInitialState(dataView)).toEqual(
|
||||
expect.objectContaining({
|
||||
dataView,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.INITIAL,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "documents loading" action', function () {
|
||||
const state: DiscoverSidebarReducerState = {
|
||||
...getInitialState(dataView),
|
||||
allFields: [dataView.fields[0]],
|
||||
};
|
||||
const resultForDocuments = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING,
|
||||
payload: {
|
||||
isPlainRecord: false,
|
||||
},
|
||||
});
|
||||
expect(resultForDocuments).toEqual(
|
||||
expect.objectContaining({
|
||||
dataView,
|
||||
allFields: state.allFields,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
})
|
||||
);
|
||||
const resultForTextBasedQuery = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING,
|
||||
payload: {
|
||||
isPlainRecord: true,
|
||||
},
|
||||
});
|
||||
expect(resultForTextBasedQuery).toEqual(
|
||||
expect.objectContaining({
|
||||
dataView,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "documents loaded" action', function () {
|
||||
const dataViewFieldName = stubDataViewWithoutTimeField.fields[0].name;
|
||||
const unmappedFieldName = 'field1';
|
||||
const fieldCounts = { [unmappedFieldName]: 1, [dataViewFieldName]: 1 };
|
||||
const state: DiscoverSidebarReducerState = getInitialState(dataView);
|
||||
const resultForDocuments = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED,
|
||||
payload: {
|
||||
isPlainRecord: false,
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
fieldCounts,
|
||||
},
|
||||
});
|
||||
expect(resultForDocuments).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: [
|
||||
...stubDataViewWithoutTimeField.fields,
|
||||
// merging in unmapped fields
|
||||
{
|
||||
displayName: unmappedFieldName,
|
||||
name: unmappedFieldName,
|
||||
type: 'unknown',
|
||||
} as DataViewField,
|
||||
],
|
||||
fieldCounts,
|
||||
status: DiscoverSidebarReducerStatus.COMPLETED,
|
||||
});
|
||||
|
||||
const resultForTextBasedQuery = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED,
|
||||
payload: {
|
||||
isPlainRecord: true,
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
fieldCounts,
|
||||
},
|
||||
});
|
||||
expect(resultForTextBasedQuery).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: [
|
||||
stubDataViewWithoutTimeField.fields.find((field) => field.name === dataViewFieldName),
|
||||
// merging in unmapped fields
|
||||
{
|
||||
displayName: 'field1',
|
||||
name: 'field1',
|
||||
type: 'unknown',
|
||||
} as DataViewField,
|
||||
],
|
||||
fieldCounts,
|
||||
status: DiscoverSidebarReducerStatus.COMPLETED,
|
||||
});
|
||||
|
||||
const resultForTextBasedQueryWhileLoading = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED,
|
||||
payload: {
|
||||
isPlainRecord: true,
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
fieldCounts: null,
|
||||
},
|
||||
});
|
||||
expect(resultForTextBasedQueryWhileLoading).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle "data view switched" action', function () {
|
||||
const state: DiscoverSidebarReducerState = getInitialState(dataView);
|
||||
const resultForTheSameDataView = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
|
||||
payload: {
|
||||
dataView: state.dataView,
|
||||
},
|
||||
});
|
||||
expect(resultForTheSameDataView).toBe(state);
|
||||
|
||||
const resultForAnotherDataView = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
|
||||
payload: {
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
},
|
||||
});
|
||||
expect(resultForAnotherDataView).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.INITIAL,
|
||||
});
|
||||
|
||||
const resultForAnotherDataViewAfterProcessing = discoverSidebarReducer(
|
||||
{
|
||||
...state,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
},
|
||||
{
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
|
||||
payload: {
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(resultForAnotherDataViewAfterProcessing).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
});
|
||||
|
||||
const resultForAnotherDataViewAfterCompleted = discoverSidebarReducer(
|
||||
{
|
||||
...state,
|
||||
status: DiscoverSidebarReducerStatus.COMPLETED,
|
||||
},
|
||||
{
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
|
||||
payload: {
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(resultForAnotherDataViewAfterCompleted).toStrictEqual({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.INITIAL,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle "reset" action', function () {
|
||||
const state: DiscoverSidebarReducerState = {
|
||||
...getInitialState(dataView),
|
||||
allFields: [dataView.fields[0]],
|
||||
fieldCounts: {},
|
||||
status: DiscoverSidebarReducerStatus.COMPLETED,
|
||||
};
|
||||
const resultForDocuments = discoverSidebarReducer(state, {
|
||||
type: DiscoverSidebarReducerActionType.RESET,
|
||||
payload: {
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
},
|
||||
});
|
||||
expect(resultForDocuments).toEqual(
|
||||
expect.objectContaining({
|
||||
dataView: stubDataViewWithoutTimeField,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.INITIAL,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { getDataViewFieldList } from './get_data_view_field_list';
|
||||
|
||||
export enum DiscoverSidebarReducerActionType {
|
||||
RESET = 'RESET',
|
||||
DATA_VIEW_SWITCHED = 'DATA_VIEW_SWITCHED',
|
||||
DOCUMENTS_LOADED = 'DOCUMENTS_LOADED',
|
||||
DOCUMENTS_LOADING = 'DOCUMENTS_LOADING',
|
||||
}
|
||||
|
||||
type DiscoverSidebarReducerAction =
|
||||
| {
|
||||
type: DiscoverSidebarReducerActionType.RESET;
|
||||
payload: {
|
||||
dataView: DataView | null | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED;
|
||||
payload: {
|
||||
dataView: DataView | null | undefined;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING;
|
||||
payload: {
|
||||
isPlainRecord: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED;
|
||||
payload: {
|
||||
fieldCounts: DiscoverSidebarReducerState['fieldCounts'];
|
||||
isPlainRecord: boolean;
|
||||
dataView: DataView | null | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export enum DiscoverSidebarReducerStatus {
|
||||
INITIAL = 'INITIAL',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
}
|
||||
|
||||
export interface DiscoverSidebarReducerState {
|
||||
dataView: DataView | null | undefined;
|
||||
allFields: DataViewField[] | null;
|
||||
fieldCounts: Record<string, number> | null;
|
||||
status: DiscoverSidebarReducerStatus;
|
||||
}
|
||||
|
||||
export function getInitialState(dataView?: DataView | null): DiscoverSidebarReducerState {
|
||||
return {
|
||||
dataView,
|
||||
allFields: null,
|
||||
fieldCounts: null,
|
||||
status: DiscoverSidebarReducerStatus.INITIAL,
|
||||
};
|
||||
}
|
||||
|
||||
export function discoverSidebarReducer(
|
||||
state: DiscoverSidebarReducerState,
|
||||
action: DiscoverSidebarReducerAction
|
||||
): DiscoverSidebarReducerState {
|
||||
switch (action.type) {
|
||||
case DiscoverSidebarReducerActionType.RESET:
|
||||
return getInitialState(action.payload.dataView);
|
||||
case DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED:
|
||||
return state.dataView === action.payload.dataView
|
||||
? state // already updated in `DOCUMENTS_LOADED`
|
||||
: {
|
||||
...state,
|
||||
dataView: action.payload.dataView,
|
||||
fieldCounts: null,
|
||||
allFields: null,
|
||||
status:
|
||||
state.status === DiscoverSidebarReducerStatus.COMPLETED
|
||||
? DiscoverSidebarReducerStatus.INITIAL
|
||||
: state.status,
|
||||
};
|
||||
case DiscoverSidebarReducerActionType.DOCUMENTS_LOADING:
|
||||
return {
|
||||
...state,
|
||||
fieldCounts: null,
|
||||
allFields: action.payload.isPlainRecord ? null : state.allFields,
|
||||
status: DiscoverSidebarReducerStatus.PROCESSING,
|
||||
};
|
||||
case DiscoverSidebarReducerActionType.DOCUMENTS_LOADED:
|
||||
const mappedAndUnmappedFields = getDataViewFieldList(
|
||||
action.payload.dataView,
|
||||
action.payload.fieldCounts,
|
||||
action.payload.isPlainRecord
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
dataView: action.payload.dataView,
|
||||
fieldCounts: action.payload.fieldCounts,
|
||||
allFields: mappedAndUnmappedFields,
|
||||
status:
|
||||
mappedAndUnmappedFields === null
|
||||
? DiscoverSidebarReducerStatus.PROCESSING
|
||||
: DiscoverSidebarReducerStatus.COMPLETED,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { DataViewListItem } from '@kbn/data-views-plugin/public';
|
||||
import { dataViewMock } from '../../__mocks__/data_view';
|
||||
|
@ -23,7 +24,7 @@ setHeaderActionMenuMounter(jest.fn());
|
|||
setUrlTracker(urlTrackerMock);
|
||||
|
||||
describe('DiscoverMainApp', () => {
|
||||
test('renders', () => {
|
||||
test('renders', async () => {
|
||||
const dataViewList = [dataViewMock].map((ip) => {
|
||||
return { ...ip, ...{ attributes: { title: ip.title } } };
|
||||
}) as unknown as DataViewListItem[];
|
||||
|
@ -35,15 +36,21 @@ describe('DiscoverMainApp', () => {
|
|||
initialEntries: ['/'],
|
||||
});
|
||||
|
||||
const component = mountWithIntl(
|
||||
<Router history={history}>
|
||||
<KibanaContextProvider services={discoverServiceMock}>
|
||||
<DiscoverMainApp {...props} />
|
||||
</KibanaContextProvider>
|
||||
</Router>
|
||||
);
|
||||
await act(async () => {
|
||||
const component = await mountWithIntl(
|
||||
<Router history={history}>
|
||||
<KibanaContextProvider services={discoverServiceMock}>
|
||||
<DiscoverMainApp {...props} />
|
||||
</KibanaContextProvider>
|
||||
</Router>
|
||||
);
|
||||
|
||||
expect(component.find(DiscoverTopNav).exists()).toBe(true);
|
||||
expect(component.find(DiscoverTopNav).prop('dataView')).toEqual(dataViewMock);
|
||||
// wait for lazy modules
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await component.update();
|
||||
|
||||
expect(component.find(DiscoverTopNav).exists()).toBe(true);
|
||||
expect(component.find(DiscoverTopNav).prop('dataView')).toEqual(dataViewMock);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
*/
|
||||
|
||||
import { calcFieldCounts } from './calc_field_counts';
|
||||
import { dataViewMock } from '../../../__mocks__/data_view';
|
||||
import { buildDataTableRecord } from '../../../utils/build_data_record';
|
||||
|
||||
describe('calcFieldCounts', () => {
|
||||
|
@ -16,7 +15,7 @@ describe('calcFieldCounts', () => {
|
|||
{ _id: '1', _index: 'test', _source: { message: 'test1', bytes: 20 } },
|
||||
{ _id: '2', _index: 'test', _source: { name: 'test2', extension: 'jpg' } },
|
||||
].map((row) => buildDataTableRecord(row));
|
||||
const result = calcFieldCounts(rows, dataViewMock);
|
||||
const result = calcFieldCounts(rows);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bytes": 1,
|
||||
|
@ -31,7 +30,7 @@ describe('calcFieldCounts', () => {
|
|||
{ _id: '1', _index: 'test', _source: { message: 'test1', bytes: 20 } },
|
||||
{ _id: '2', _index: 'test', _source: { name: 'test2', extension: 'jpg' } },
|
||||
].map((row) => buildDataTableRecord(row));
|
||||
const result = calcFieldCounts(rows, dataViewMock);
|
||||
const result = calcFieldCounts(rows);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bytes": 1,
|
||||
|
|
|
@ -5,24 +5,24 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { DataTableRecord } from '../../../types';
|
||||
|
||||
/**
|
||||
* This function is calculating stats of the available fields, for usage in sidebar and sharing
|
||||
* Note that this values aren't displayed, but used for internal calculations
|
||||
*/
|
||||
export function calcFieldCounts(rows?: DataTableRecord[], dataView?: DataView) {
|
||||
export function calcFieldCounts(rows?: DataTableRecord[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
if (!rows || !dataView) {
|
||||
if (!rows) {
|
||||
return {};
|
||||
}
|
||||
for (const hit of rows) {
|
||||
|
||||
rows.forEach((hit) => {
|
||||
const fields = Object.keys(hit.flattened);
|
||||
for (const fieldName of fields) {
|
||||
fields.forEach((fieldName) => {
|
||||
counts[fieldName] = (counts[fieldName] || 0) + 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
|
|
@ -92,6 +92,10 @@ describe('test fetchCharts', () => {
|
|||
"interval": "auto",
|
||||
"min_doc_count": 1,
|
||||
"scaleMetricValues": false,
|
||||
"timeRange": Object {
|
||||
"from": "now-15m",
|
||||
"to": "now",
|
||||
},
|
||||
"useNormalizedEsInterval": true,
|
||||
"used_interval": "0ms",
|
||||
},
|
||||
|
|
|
@ -14,7 +14,7 @@ export const DISCOVER_TOUR_STEP_ANCHOR_IDS = {
|
|||
};
|
||||
|
||||
export const DISCOVER_TOUR_STEP_ANCHORS = {
|
||||
addFields: `#${DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}`,
|
||||
addFields: `[data-test-subj="fieldListGroupedAvailableFields-count"], #${DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}`,
|
||||
reorderColumns: '[data-test-subj="dataGridColumnSelectorButton"]',
|
||||
sort: '[data-test-subj="dataGridColumnSortingButton"]',
|
||||
changeRowHeight: '[data-test-subj="dataGridDisplaySelectorButton"]',
|
||||
|
|
|
@ -36,4 +36,13 @@ describe('getTypeForFieldIcon', () => {
|
|||
} as DataViewField)
|
||||
).toBe('version');
|
||||
});
|
||||
|
||||
it('extracts type for meta fields', () => {
|
||||
expect(
|
||||
getTypeForFieldIcon({
|
||||
type: 'string',
|
||||
esTypes: ['_id'],
|
||||
} as DataViewField)
|
||||
).toBe('string');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,5 +13,10 @@ import { DataViewField } from '@kbn/data-views-plugin/common';
|
|||
*
|
||||
* We define custom logic for Discover in order to distinguish between various "string" types.
|
||||
*/
|
||||
export const getTypeForFieldIcon = (field: DataViewField) =>
|
||||
field.type === 'string' && field.esTypes ? field.esTypes[0] : field.type;
|
||||
export const getTypeForFieldIcon = (field: DataViewField) => {
|
||||
const esType = field.esTypes?.[0] || null;
|
||||
if (esType && ['_id', '_index'].includes(esType)) {
|
||||
return field.type;
|
||||
}
|
||||
return field.type === 'string' && esType ? esType : field.type;
|
||||
};
|
||||
|
|
|
@ -81,18 +81,16 @@ const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({
|
|||
...
|
||||
});
|
||||
const fieldsExistenceReader = useExistingFieldsReader()
|
||||
const { fieldGroups } = useGroupedFields({
|
||||
dataViewId: currentDataViewId,
|
||||
allFields,
|
||||
fieldsExistenceReader,
|
||||
const fieldListGroupedProps = useGroupedFields({
|
||||
dataViewId: currentDataViewId, // pass `null` here for text-based queries to skip fields existence check
|
||||
allFields, // pass `null` to show loading indicators
|
||||
fieldsExistenceReader, // pass `undefined` for text-based queries
|
||||
...
|
||||
});
|
||||
|
||||
// and now we can render a field list
|
||||
<FieldListGrouped
|
||||
fieldGroups={fieldGroups}
|
||||
fieldsExistenceStatus={fieldsExistenceReader.getFieldsExistenceStatus(currentDataViewId)}
|
||||
fieldsExistInIndex={!!allFields.length}
|
||||
{...fieldListGroupedProps}
|
||||
renderFieldItem={renderFieldItem}
|
||||
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
|
||||
/>
|
||||
|
|
|
@ -132,7 +132,7 @@ export function buildFieldList(indexPattern: DataView, metaFields: string[]): Fi
|
|||
script: field.script,
|
||||
// id is a special case - it doesn't show up in the meta field list,
|
||||
// but as it's not part of source, it has to be handled separately.
|
||||
isMeta: metaFields.includes(field.name) || field.name === '_id',
|
||||
isMeta: metaFields?.includes(field.name) || field.name === '_id',
|
||||
runtimeField: !field.isMapped ? field.runtimeField : undefined,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -24,7 +24,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
let defaultProps: FieldListGroupedProps<DataViewField>;
|
||||
let mockedServices: GroupedFieldsParams<DataViewField>['services'];
|
||||
const allFields = dataView.fields;
|
||||
// 5 times more fields. Added fields will be treated as empty as they are not a part of the data view.
|
||||
// 5 times more fields. Added fields will be treated as Unmapped as they are not a part of the data view.
|
||||
const manyFields = [...new Array(5)].flatMap((_, index) =>
|
||||
allFields.map((field) => {
|
||||
return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` });
|
||||
|
@ -44,6 +44,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
defaultProps = {
|
||||
fieldGroups: {},
|
||||
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
|
||||
scrollToTopResetCounter: 0,
|
||||
fieldsExistInIndex: true,
|
||||
screenReaderDescriptionForSearchInputId: 'testId',
|
||||
renderFieldItem: jest.fn(({ field, itemIndex, groupIndex }) => (
|
||||
|
@ -268,10 +269,23 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
|
||||
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
expect(
|
||||
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
|
||||
).toStrictEqual([25, 0, 0]);
|
||||
).toStrictEqual([25, 0, 0, 0]);
|
||||
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
.find('[data-test-subj="fieldListGroupedUnmappedFields"]')
|
||||
.find('button')
|
||||
.first()
|
||||
.simulate('click');
|
||||
await wrapper.update();
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
|
||||
).toStrictEqual([25, 50, 0, 0]);
|
||||
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
|
@ -284,20 +298,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
|
||||
).toStrictEqual([25, 50, 0]);
|
||||
|
||||
await act(async () => {
|
||||
await wrapper
|
||||
.find('[data-test-subj="fieldListGroupedMetaFields"]')
|
||||
.find('button')
|
||||
.first()
|
||||
.simulate('click');
|
||||
await wrapper.update();
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
|
||||
).toStrictEqual([25, 88, 0]);
|
||||
).toStrictEqual([25, 88, 0, 0]);
|
||||
});
|
||||
|
||||
it('renders correctly when filtered', async () => {
|
||||
|
@ -315,7 +316,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
|
||||
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
|
||||
await act(async () => {
|
||||
await wrapper.setProps({
|
||||
|
@ -329,7 +330,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('2 available fields. 8 empty fields. 0 meta fields.');
|
||||
).toBe('2 available fields. 8 unmapped fields. 0 empty fields. 0 meta fields.');
|
||||
|
||||
await act(async () => {
|
||||
await wrapper.setProps({
|
||||
|
@ -343,7 +344,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('0 available fields. 12 empty fields. 3 meta fields.');
|
||||
).toBe('0 available fields. 12 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
});
|
||||
|
||||
it('renders correctly when non-supported fields are filtered out', async () => {
|
||||
|
@ -361,7 +362,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
|
||||
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
|
||||
await act(async () => {
|
||||
await wrapper.setProps({
|
||||
|
@ -375,7 +376,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('23 available fields. 104 empty fields. 3 meta fields.');
|
||||
).toBe('23 available fields. 104 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
});
|
||||
|
||||
it('renders correctly when selected fields are present', async () => {
|
||||
|
@ -393,7 +394,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
|
||||
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
|
||||
|
||||
await act(async () => {
|
||||
await wrapper.setProps({
|
||||
|
@ -408,6 +409,30 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
|
|||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe('2 selected fields. 25 available fields. 112 empty fields. 3 meta fields.');
|
||||
).toBe(
|
||||
'2 selected fields. 25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders correctly when popular fields limit and custom selected fields are present', async () => {
|
||||
const hookParams = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: manyFields,
|
||||
popularFieldsLimit: 10,
|
||||
sortedSelectedFields: [manyFields[0], manyFields[1]],
|
||||
};
|
||||
const wrapper = await mountGroupedList({
|
||||
listProps: {
|
||||
...defaultProps,
|
||||
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
|
||||
},
|
||||
hookParams,
|
||||
});
|
||||
|
||||
expect(
|
||||
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
|
||||
).toBe(
|
||||
'2 selected fields. 10 popular fields. 25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,14 +7,14 @@
|
|||
*/
|
||||
|
||||
import { partition, throttle } from 'lodash';
|
||||
import React, { useState, Fragment, useCallback, useMemo } from 'react';
|
||||
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiScreenReaderOnly, EuiSpacer } from '@elastic/eui';
|
||||
import { type DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import { NoFieldsCallout } from './no_fields_callout';
|
||||
import { FieldsAccordion, type FieldsAccordionProps } from './fields_accordion';
|
||||
import { FieldsAccordion, type FieldsAccordionProps, getFieldKey } from './fields_accordion';
|
||||
import type { FieldListGroups, FieldListItem } from '../../types';
|
||||
import { ExistenceFetchStatus } from '../../types';
|
||||
import { ExistenceFetchStatus, FieldsGroup, FieldsGroupNames } from '../../types';
|
||||
import './field_list_grouped.scss';
|
||||
|
||||
const PAGINATION_SIZE = 50;
|
||||
|
@ -33,6 +33,7 @@ export interface FieldListGroupedProps<T extends FieldListItem> {
|
|||
fieldsExistenceStatus: ExistenceFetchStatus;
|
||||
fieldsExistInIndex: boolean;
|
||||
renderFieldItem: FieldsAccordionProps<T>['renderFieldItem'];
|
||||
scrollToTopResetCounter: number;
|
||||
screenReaderDescriptionForSearchInputId?: string;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
@ -42,6 +43,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
fieldsExistenceStatus,
|
||||
fieldsExistInIndex,
|
||||
renderFieldItem,
|
||||
scrollToTopResetCounter,
|
||||
screenReaderDescriptionForSearchInputId,
|
||||
'data-test-subj': dataTestSubject = 'fieldListGrouped',
|
||||
}: FieldListGroupedProps<T>) {
|
||||
|
@ -60,6 +62,14 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset the scroll if we have made material changes to the field list
|
||||
if (scrollContainer && scrollToTopResetCounter) {
|
||||
scrollContainer.scrollTop = 0;
|
||||
setPageSize(PAGINATION_SIZE);
|
||||
}
|
||||
}, [scrollToTopResetCounter, scrollContainer]);
|
||||
|
||||
const lazyScroll = useCallback(() => {
|
||||
if (scrollContainer) {
|
||||
const nearBottom =
|
||||
|
@ -93,9 +103,12 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
);
|
||||
}, [pageSize, fieldGroupsToShow, accordionState]);
|
||||
|
||||
const hasSpecialFields = Boolean(fieldGroupsToCollapse[0]?.[1]?.fields?.length);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="unifiedFieldList__fieldListGrouped"
|
||||
data-test-subj={`${dataTestSubject}FieldGroups`}
|
||||
ref={(el) => {
|
||||
if (el && !el.dataset.dynamicScroll) {
|
||||
el.dataset.dynamicScroll = 'true';
|
||||
|
@ -114,9 +127,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
>
|
||||
{hasSyncedExistingFields
|
||||
? [
|
||||
fieldGroups.SelectedFields &&
|
||||
(!fieldGroups.SelectedFields?.hideIfEmpty ||
|
||||
fieldGroups.SelectedFields?.fields?.length > 0) &&
|
||||
shouldIncludeGroupDescriptionInAria(fieldGroups.SelectedFields) &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForSelectedFieldsLiveRegion',
|
||||
{
|
||||
|
@ -127,6 +138,17 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
},
|
||||
}
|
||||
),
|
||||
shouldIncludeGroupDescriptionInAria(fieldGroups.PopularFields) &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForPopularFieldsLiveRegion',
|
||||
{
|
||||
defaultMessage:
|
||||
'{popularFields} popular {popularFields, plural, one {field} other {fields}}.',
|
||||
values: {
|
||||
popularFields: fieldGroups.PopularFields?.fields?.length || 0,
|
||||
},
|
||||
}
|
||||
),
|
||||
fieldGroups.AvailableFields?.fields &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForAvailableFieldsLiveRegion',
|
||||
|
@ -138,9 +160,18 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
},
|
||||
}
|
||||
),
|
||||
fieldGroups.EmptyFields &&
|
||||
(!fieldGroups.EmptyFields?.hideIfEmpty ||
|
||||
fieldGroups.EmptyFields?.fields?.length > 0) &&
|
||||
shouldIncludeGroupDescriptionInAria(fieldGroups.UnmappedFields) &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForUnmappedFieldsLiveRegion',
|
||||
{
|
||||
defaultMessage:
|
||||
'{unmappedFields} unmapped {unmappedFields, plural, one {field} other {fields}}.',
|
||||
values: {
|
||||
unmappedFields: fieldGroups.UnmappedFields?.fields?.length || 0,
|
||||
},
|
||||
}
|
||||
),
|
||||
shouldIncludeGroupDescriptionInAria(fieldGroups.EmptyFields) &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForEmptyFieldsLiveRegion',
|
||||
{
|
||||
|
@ -151,9 +182,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
},
|
||||
}
|
||||
),
|
||||
fieldGroups.MetaFields &&
|
||||
(!fieldGroups.MetaFields?.hideIfEmpty ||
|
||||
fieldGroups.MetaFields?.fields?.length > 0) &&
|
||||
shouldIncludeGroupDescriptionInAria(fieldGroups.MetaFields) &&
|
||||
i18n.translate(
|
||||
'unifiedFieldList.fieldListGrouped.fieldSearchForMetaFieldsLiveRegion',
|
||||
{
|
||||
|
@ -171,16 +200,26 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
</div>
|
||||
</EuiScreenReaderOnly>
|
||||
)}
|
||||
<ul>
|
||||
{fieldGroupsToCollapse.flatMap(([, { fields }]) =>
|
||||
fields.map((field, index) => (
|
||||
<Fragment key={field.name}>
|
||||
{renderFieldItem({ field, itemIndex: index, groupIndex: 0, hideDetails: true })}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<EuiSpacer size="s" />
|
||||
{hasSpecialFields && (
|
||||
<>
|
||||
<ul>
|
||||
{fieldGroupsToCollapse.flatMap(([key, { fields }]) =>
|
||||
fields.map((field, index) => (
|
||||
<Fragment key={getFieldKey(field)}>
|
||||
{renderFieldItem({
|
||||
field,
|
||||
itemIndex: index,
|
||||
groupIndex: 0,
|
||||
groupName: key as FieldsGroupNames,
|
||||
hideDetails: true,
|
||||
})}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
<EuiSpacer size="s" />
|
||||
</>
|
||||
)}
|
||||
{fieldGroupsToShow.map(([key, fieldGroup], index) => {
|
||||
const hidden = Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length;
|
||||
if (hidden) {
|
||||
|
@ -199,6 +238,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length}
|
||||
paginatedFields={paginatedFields[key]}
|
||||
groupIndex={index + 1}
|
||||
groupName={key as FieldsGroupNames}
|
||||
onToggle={(open) => {
|
||||
setAccordionState((s) => ({
|
||||
...s,
|
||||
|
@ -224,6 +264,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
|
|||
isAffectedByFieldFilter={fieldGroup.fieldCount !== fieldGroup.fields.length}
|
||||
fieldsExistInIndex={!!fieldsExistInIndex}
|
||||
defaultNoFieldsMessage={fieldGroup.defaultNoFieldsMessage}
|
||||
data-test-subj={`${dataTestSubject}${key}NoFieldsCallout`}
|
||||
/>
|
||||
)}
|
||||
renderFieldItem={renderFieldItem}
|
||||
|
@ -243,3 +284,13 @@ const FieldListGrouped = React.memo(InnerFieldListGrouped) as GenericFieldListGr
|
|||
// Necessary for React.lazy
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default FieldListGrouped;
|
||||
|
||||
function shouldIncludeGroupDescriptionInAria<T extends FieldListItem>(
|
||||
group: FieldsGroup<T> | undefined
|
||||
): boolean {
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
// has some fields or an empty list should be still shown
|
||||
return group.fields?.length > 0 || !group.hideIfEmpty;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/
|
|||
import { EuiLoadingSpinner, EuiNotificationBadge, EuiText } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { FieldsAccordion, FieldsAccordionProps } from './fields_accordion';
|
||||
import { FieldListItem } from '../../types';
|
||||
import { FieldListItem, FieldsGroupNames } from '../../types';
|
||||
|
||||
describe('UnifiedFieldList <FieldsAccordion />', () => {
|
||||
let defaultProps: FieldsAccordionProps<FieldListItem>;
|
||||
|
@ -21,7 +21,8 @@ describe('UnifiedFieldList <FieldsAccordion />', () => {
|
|||
defaultProps = {
|
||||
initialIsOpen: true,
|
||||
onToggle: jest.fn(),
|
||||
groupIndex: 0,
|
||||
groupIndex: 1,
|
||||
groupName: FieldsGroupNames.AvailableFields,
|
||||
id: 'id',
|
||||
label: 'label-test',
|
||||
hasLoaded: true,
|
||||
|
|
|
@ -18,7 +18,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import { type DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { FieldListItem } from '../../types';
|
||||
import { type FieldListItem, FieldsGroupNames } from '../../types';
|
||||
import './fields_accordion.scss';
|
||||
|
||||
export interface FieldsAccordionProps<T extends FieldListItem> {
|
||||
|
@ -32,12 +32,14 @@ export interface FieldsAccordionProps<T extends FieldListItem> {
|
|||
hideDetails?: boolean;
|
||||
isFiltered: boolean;
|
||||
groupIndex: number;
|
||||
groupName: FieldsGroupNames;
|
||||
paginatedFields: T[];
|
||||
renderFieldItem: (params: {
|
||||
field: T;
|
||||
hideDetails?: boolean;
|
||||
itemIndex: number;
|
||||
groupIndex: number;
|
||||
groupName: FieldsGroupNames;
|
||||
}) => JSX.Element;
|
||||
renderCallout: () => JSX.Element;
|
||||
showExistenceFetchError?: boolean;
|
||||
|
@ -55,6 +57,7 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
|
|||
hideDetails,
|
||||
isFiltered,
|
||||
groupIndex,
|
||||
groupName,
|
||||
paginatedFields,
|
||||
renderFieldItem,
|
||||
renderCallout,
|
||||
|
@ -99,6 +102,9 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
|
|||
content={i18n.translate('unifiedFieldList.fieldsAccordion.existenceErrorLabel', {
|
||||
defaultMessage: "Field information can't be loaded",
|
||||
})}
|
||||
iconProps={{
|
||||
'data-test-subj': `${id}-fetchWarning`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -128,7 +134,7 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
|
|||
);
|
||||
}
|
||||
|
||||
return <EuiLoadingSpinner size="m" />;
|
||||
return <EuiLoadingSpinner size="m" data-test-subj={`${id}-countLoading`} />;
|
||||
}, [showExistenceFetchError, showExistenceFetchTimeout, hasLoaded, isFiltered, id, fieldsCount]);
|
||||
|
||||
return (
|
||||
|
@ -146,8 +152,8 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
|
|||
<ul className="unifiedFieldList__fieldsAccordion__fieldItems">
|
||||
{paginatedFields &&
|
||||
paginatedFields.map((field, index) => (
|
||||
<Fragment key={field.name}>
|
||||
{renderFieldItem({ field, itemIndex: index, groupIndex, hideDetails })}
|
||||
<Fragment key={getFieldKey(field)}>
|
||||
{renderFieldItem({ field, itemIndex: index, groupIndex, groupName, hideDetails })}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
|
@ -159,3 +165,6 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
|
|||
}
|
||||
|
||||
export const FieldsAccordion = React.memo(InnerFieldsAccordion) as typeof InnerFieldsAccordion;
|
||||
|
||||
export const getFieldKey = (field: FieldListItem): string =>
|
||||
`${field.name}-${field.displayName}-${field.type}`;
|
||||
|
|
|
@ -16,6 +16,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsExist"
|
||||
size="s"
|
||||
title="No fields exist in this data view."
|
||||
/>
|
||||
|
@ -26,6 +27,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="There are no fields."
|
||||
/>
|
||||
|
@ -38,6 +40,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="No empty fields"
|
||||
/>
|
||||
|
@ -51,6 +54,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="No fields match the selected filters."
|
||||
>
|
||||
|
@ -78,6 +82,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="There are no available fields that contain data."
|
||||
>
|
||||
|
@ -108,6 +113,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="No fields match the selected filters."
|
||||
>
|
||||
|
@ -139,6 +145,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
|
|||
expect(component).toMatchInlineSnapshot(`
|
||||
<EuiCallOut
|
||||
color="warning"
|
||||
data-test-subj="noFieldsCallout-noFieldsMatch"
|
||||
size="s"
|
||||
title="No fields match the selected filters."
|
||||
>
|
||||
|
|
|
@ -23,12 +23,14 @@ export const NoFieldsCallout = ({
|
|||
isAffectedByFieldFilter = false,
|
||||
isAffectedByTimerange = false,
|
||||
isAffectedByGlobalFilter = false,
|
||||
'data-test-subj': dataTestSubject = 'noFieldsCallout',
|
||||
}: {
|
||||
fieldsExistInIndex: boolean;
|
||||
isAffectedByFieldFilter?: boolean;
|
||||
defaultNoFieldsMessage?: string;
|
||||
isAffectedByTimerange?: boolean;
|
||||
isAffectedByGlobalFilter?: boolean;
|
||||
'data-test-subj'?: string;
|
||||
}) => {
|
||||
if (!fieldsExistInIndex) {
|
||||
return (
|
||||
|
@ -38,6 +40,7 @@ export const NoFieldsCallout = ({
|
|||
title={i18n.translate('unifiedFieldList.fieldList.noFieldsCallout.noFieldsLabel', {
|
||||
defaultMessage: 'No fields exist in this data view.',
|
||||
})}
|
||||
data-test-subj={`${dataTestSubject}-noFieldsExist`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -53,6 +56,7 @@ export const NoFieldsCallout = ({
|
|||
})
|
||||
: defaultNoFieldsMessage
|
||||
}
|
||||
data-test-subj={`${dataTestSubject}-noFieldsMatch`}
|
||||
>
|
||||
{(isAffectedByTimerange || isAffectedByFieldFilter || isAffectedByGlobalFilter) && (
|
||||
<>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import { EuiLoadingSpinner, EuiProgress } from '@elastic/eui';
|
||||
import { coreMock } from '@kbn/core/public/mocks';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
|
@ -120,6 +121,18 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
async function mountComponent(component: React.ReactElement): Promise<ReactWrapper> {
|
||||
let wrapper: ReactWrapper;
|
||||
await act(async () => {
|
||||
wrapper = await mountWithIntl(component);
|
||||
// wait for lazy modules if any
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await wrapper.update();
|
||||
});
|
||||
|
||||
return wrapper!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(loadFieldStats as jest.Mock).mockReset();
|
||||
(loadFieldStats as jest.Mock).mockImplementation(() => Promise.resolve({}));
|
||||
|
@ -134,7 +147,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats
|
||||
{...defaultProps}
|
||||
query={{ query: 'geo.src : "US"', language: 'kuery' }}
|
||||
|
@ -149,8 +162,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalledWith({
|
||||
abortController: new AbortController(),
|
||||
services: { data: mockedServices.data },
|
||||
|
@ -260,33 +271,27 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
|
||||
it('should not request field stats for range fields', async () => {
|
||||
const wrapper = await mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats {...defaultProps} field={dataView.fields.find((f) => f.name === 'ip_range')!} />
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.text()).toBe('Analysis is not available for this field.');
|
||||
});
|
||||
|
||||
it('should not request field stats for geo fields', async () => {
|
||||
const wrapper = await mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats {...defaultProps} field={dataView.fields.find((f) => f.name === 'geo_shape')!} />
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.text()).toBe('Analysis is not available for this field.');
|
||||
});
|
||||
|
||||
it('should render a message if no data is found', async () => {
|
||||
const wrapper = await mountWithIntl(<FieldStats {...defaultProps} />);
|
||||
|
||||
await wrapper.update();
|
||||
const wrapper = await mountComponent(<FieldStats {...defaultProps} />);
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalled();
|
||||
|
||||
|
@ -302,9 +307,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(<FieldStats {...defaultProps} />);
|
||||
|
||||
await wrapper.update();
|
||||
const wrapper = await mountComponent(<FieldStats {...defaultProps} />);
|
||||
|
||||
await act(async () => {
|
||||
resolveFunction!({
|
||||
|
@ -330,7 +333,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats
|
||||
{...defaultProps}
|
||||
query={{ language: 'kuery', query: '' }}
|
||||
|
@ -340,8 +343,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalledWith({
|
||||
abortController: new AbortController(),
|
||||
services: { data: mockedServices.data },
|
||||
|
@ -433,7 +434,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats
|
||||
{...defaultProps}
|
||||
field={
|
||||
|
@ -446,8 +447,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
|
@ -507,7 +506,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats
|
||||
{...defaultProps}
|
||||
field={dataView.fields[0]}
|
||||
|
@ -518,8 +517,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalledWith({
|
||||
abortController: new AbortController(),
|
||||
services: { data: mockedServices.data },
|
||||
|
@ -615,7 +612,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
|
||||
const field = dataView.fields.find((f) => f.name === 'machine.ram')!;
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
const wrapper = await mountComponent(
|
||||
<FieldStats
|
||||
{...defaultProps}
|
||||
field={field}
|
||||
|
@ -626,8 +623,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
|
|||
/>
|
||||
);
|
||||
|
||||
await wrapper.update();
|
||||
|
||||
expect(loadFieldStats).toHaveBeenCalledWith({
|
||||
abortController: new AbortController(),
|
||||
services: { data: mockedServices.data },
|
||||
|
|
|
@ -378,33 +378,36 @@ const FieldStatsComponent: React.FC<FieldStatsProps> = ({
|
|||
|
||||
if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) {
|
||||
title = (
|
||||
<EuiButtonGroup
|
||||
buttonSize="compressed"
|
||||
isFullWidth
|
||||
legend={i18n.translate('unifiedFieldList.fieldStats.displayToggleLegend', {
|
||||
defaultMessage: 'Toggle either the',
|
||||
})}
|
||||
options={[
|
||||
{
|
||||
label: i18n.translate('unifiedFieldList.fieldStats.topValuesLabel', {
|
||||
defaultMessage: 'Top values',
|
||||
}),
|
||||
id: 'topValues',
|
||||
'data-test-subj': `${dataTestSubject}-buttonGroup-topValuesButton`,
|
||||
},
|
||||
{
|
||||
label: i18n.translate('unifiedFieldList.fieldStats.fieldDistributionLabel', {
|
||||
defaultMessage: 'Distribution',
|
||||
}),
|
||||
id: 'histogram',
|
||||
'data-test-subj': `${dataTestSubject}-buttonGroup-distributionButton`,
|
||||
},
|
||||
]}
|
||||
onChange={(optionId: string) => {
|
||||
setShowingHistogram(optionId === 'histogram');
|
||||
}}
|
||||
idSelected={showingHistogram ? 'histogram' : 'topValues'}
|
||||
/>
|
||||
<>
|
||||
<EuiButtonGroup
|
||||
buttonSize="compressed"
|
||||
isFullWidth
|
||||
legend={i18n.translate('unifiedFieldList.fieldStats.displayToggleLegend', {
|
||||
defaultMessage: 'Toggle either the',
|
||||
})}
|
||||
options={[
|
||||
{
|
||||
label: i18n.translate('unifiedFieldList.fieldStats.topValuesLabel', {
|
||||
defaultMessage: 'Top values',
|
||||
}),
|
||||
id: 'topValues',
|
||||
'data-test-subj': `${dataTestSubject}-buttonGroup-topValuesButton`,
|
||||
},
|
||||
{
|
||||
label: i18n.translate('unifiedFieldList.fieldStats.fieldDistributionLabel', {
|
||||
defaultMessage: 'Distribution',
|
||||
}),
|
||||
id: 'histogram',
|
||||
'data-test-subj': `${dataTestSubject}-buttonGroup-distributionButton`,
|
||||
},
|
||||
]}
|
||||
onChange={(optionId: string) => {
|
||||
setShowingHistogram(optionId === 'histogram');
|
||||
}}
|
||||
idSelected={showingHistogram ? 'histogram' : 'topValues'}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
</>
|
||||
);
|
||||
} else if (field.type === 'date') {
|
||||
title = (
|
||||
|
|
259
src/plugins/unified_field_list/public/hooks/__snapshots__/use_grouped_fields.test.tsx.snap
generated
Normal file
259
src/plugins/unified_field_list/public/hooks/__snapshots__/use_grouped_fields.test.tsx.snap
generated
Normal file
|
@ -0,0 +1,259 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`UnifiedFieldList useGroupedFields() should work correctly for no data 1`] = `
|
||||
Object {
|
||||
"AvailableFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no available fields that contain data.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Available fields",
|
||||
},
|
||||
"EmptyFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no empty fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that don't have any values based on your filters.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Empty fields",
|
||||
},
|
||||
"MetaFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no meta fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Meta fields",
|
||||
},
|
||||
"PopularFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that your organization frequently uses, from most to least popular.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Popular fields",
|
||||
},
|
||||
"SelectedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Selected fields",
|
||||
},
|
||||
"SpecialFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": false,
|
||||
"title": "",
|
||||
},
|
||||
"UnmappedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that aren't explicitly mapped to a field data type.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Unmapped fields",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UnifiedFieldList useGroupedFields() should work correctly in loading state 1`] = `
|
||||
Object {
|
||||
"AvailableFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no available fields that contain data.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Available fields",
|
||||
},
|
||||
"EmptyFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no empty fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that don't have any values based on your filters.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Empty fields",
|
||||
},
|
||||
"MetaFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no meta fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Meta fields",
|
||||
},
|
||||
"PopularFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that your organization frequently uses, from most to least popular.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Popular fields",
|
||||
},
|
||||
"SelectedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Selected fields",
|
||||
},
|
||||
"SpecialFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": false,
|
||||
"title": "",
|
||||
},
|
||||
"UnmappedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that aren't explicitly mapped to a field data type.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Unmapped fields",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`UnifiedFieldList useGroupedFields() should work correctly when global filters are set 1`] = `
|
||||
Object {
|
||||
"AvailableFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no available fields that contain data.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"isAffectedByGlobalFilter": true,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Available fields",
|
||||
},
|
||||
"EmptyFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no empty fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that don't have any values based on your filters.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Empty fields",
|
||||
},
|
||||
"MetaFields": Object {
|
||||
"defaultNoFieldsMessage": "There are no meta fields.",
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": false,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Meta fields",
|
||||
},
|
||||
"PopularFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that your organization frequently uses, from most to least popular.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": true,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Popular fields",
|
||||
},
|
||||
"SelectedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": true,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": true,
|
||||
"showInAccordion": true,
|
||||
"title": "Selected fields",
|
||||
},
|
||||
"SpecialFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"hideDetails": true,
|
||||
"isAffectedByGlobalFilter": false,
|
||||
"isAffectedByTimeFilter": false,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": false,
|
||||
"title": "",
|
||||
},
|
||||
"UnmappedFields": Object {
|
||||
"fieldCount": 0,
|
||||
"fields": Array [],
|
||||
"helpText": "Fields that aren't explicitly mapped to a field data type.",
|
||||
"hideDetails": false,
|
||||
"hideIfEmpty": true,
|
||||
"isAffectedByGlobalFilter": true,
|
||||
"isAffectedByTimeFilter": true,
|
||||
"isInitiallyOpen": false,
|
||||
"showInAccordion": true,
|
||||
"title": "Unmapped fields",
|
||||
},
|
||||
}
|
||||
`;
|
|
@ -15,7 +15,6 @@ import {
|
|||
DataPublicPluginStart,
|
||||
DataViewsContract,
|
||||
getEsQueryConfig,
|
||||
UI_SETTINGS,
|
||||
} from '@kbn/data-plugin/public';
|
||||
import { type DataView } from '@kbn/data-plugin/common';
|
||||
import { loadFieldExisting } from '../services/field_existing';
|
||||
|
@ -32,11 +31,12 @@ export interface ExistingFieldsInfo {
|
|||
}
|
||||
|
||||
export interface ExistingFieldsFetcherParams {
|
||||
disableAutoFetching?: boolean;
|
||||
dataViews: DataView[];
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
query: Query | AggregateQuery;
|
||||
filters: Filter[];
|
||||
fromDate: string | undefined; // fetching will be skipped if `undefined`
|
||||
toDate: string | undefined;
|
||||
query: Query | AggregateQuery | undefined;
|
||||
filters: Filter[] | undefined;
|
||||
services: {
|
||||
core: Pick<CoreStart, 'uiSettings'>;
|
||||
data: DataPublicPluginStart;
|
||||
|
@ -89,7 +89,7 @@ export const useExistingFieldsFetcher = (
|
|||
dataViewId: string | undefined;
|
||||
fetchId: string;
|
||||
}): Promise<void> => {
|
||||
if (!dataViewId) {
|
||||
if (!dataViewId || !query || !fromDate || !toDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -123,7 +123,7 @@ export const useExistingFieldsFetcher = (
|
|||
dslQuery: await buildSafeEsQuery(
|
||||
dataView,
|
||||
query,
|
||||
filters,
|
||||
filters || [],
|
||||
getEsQueryConfig(core.uiSettings)
|
||||
),
|
||||
fromDate,
|
||||
|
@ -137,11 +137,11 @@ export const useExistingFieldsFetcher = (
|
|||
|
||||
const existingFieldNames = result?.existingFieldNames || [];
|
||||
|
||||
const metaFields = core.uiSettings.get(UI_SETTINGS.META_FIELDS) || [];
|
||||
if (
|
||||
!existingFieldNames.filter((fieldName) => !metaFields.includes?.(fieldName)).length &&
|
||||
onNoData &&
|
||||
numberOfFetches === 1 &&
|
||||
onNoData
|
||||
!existingFieldNames.filter((fieldName) => !dataView?.metaFields?.includes(fieldName))
|
||||
.length
|
||||
) {
|
||||
onNoData(dataViewId);
|
||||
}
|
||||
|
@ -173,12 +173,17 @@ export const useExistingFieldsFetcher = (
|
|||
async (dataViewId?: string) => {
|
||||
const fetchId = generateId();
|
||||
lastFetchId = fetchId;
|
||||
|
||||
const options = {
|
||||
fetchId,
|
||||
dataViewId,
|
||||
...params,
|
||||
};
|
||||
// refetch only for the specified data view
|
||||
if (dataViewId) {
|
||||
await fetchFieldsExistenceInfo({
|
||||
fetchId,
|
||||
...options,
|
||||
dataViewId,
|
||||
...params,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -186,9 +191,8 @@ export const useExistingFieldsFetcher = (
|
|||
await Promise.all(
|
||||
params.dataViews.map((dataView) =>
|
||||
fetchFieldsExistenceInfo({
|
||||
fetchId,
|
||||
...options,
|
||||
dataViewId: dataView.id,
|
||||
...params,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
@ -205,8 +209,10 @@ export const useExistingFieldsFetcher = (
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
refetchFieldsExistenceInfo();
|
||||
}, [refetchFieldsExistenceInfo]);
|
||||
if (!params.disableAutoFetching) {
|
||||
refetchFieldsExistenceInfo();
|
||||
}
|
||||
}, [refetchFieldsExistenceInfo, params.disableAutoFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
@ -20,6 +20,12 @@ import { ExistenceFetchStatus, FieldListGroups, FieldsGroupNames } from '../type
|
|||
describe('UnifiedFieldList useGroupedFields()', () => {
|
||||
let mockedServices: GroupedFieldsParams<DataViewField>['services'];
|
||||
const allFields = dataView.fields;
|
||||
// Added fields will be treated as Unmapped as they are not a part of the data view.
|
||||
const allFieldsIncludingUnmapped = [...new Array(2)].flatMap((_, index) =>
|
||||
allFields.map((field) => {
|
||||
return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` });
|
||||
})
|
||||
);
|
||||
const anotherDataView = createStubDataView({
|
||||
spec: {
|
||||
id: 'another-data-view',
|
||||
|
@ -39,14 +45,43 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should work correctly in loading state', async () => {
|
||||
const props: GroupedFieldsParams<DataViewField> = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: null,
|
||||
services: mockedServices,
|
||||
};
|
||||
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
|
||||
initialProps: props,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.fieldGroups).toMatchSnapshot();
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
|
||||
expect(result.current.fieldsExistInIndex).toBe(false);
|
||||
expect(result.current.scrollToTopResetCounter).toBeTruthy();
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
dataViewId: null, // for text-based queries
|
||||
allFields: null,
|
||||
});
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
expect(result.current.scrollToTopResetCounter).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should work correctly for no data', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
dataViewId: dataView.id!,
|
||||
allFields: [],
|
||||
services: mockedServices,
|
||||
})
|
||||
);
|
||||
const props: GroupedFieldsParams<DataViewField> = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: [],
|
||||
services: mockedServices,
|
||||
};
|
||||
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
|
||||
initialProps: props,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
|
@ -59,20 +94,36 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-0',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-0',
|
||||
]);
|
||||
|
||||
expect(fieldGroups).toMatchSnapshot();
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(false);
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
dataViewId: null, // for text-based queries
|
||||
allFields: [],
|
||||
});
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('should work correctly with fields', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
})
|
||||
);
|
||||
const props: GroupedFieldsParams<DataViewField> = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
};
|
||||
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
|
||||
initialProps: props,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
|
@ -85,48 +136,116 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-25',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
dataViewId: null, // for text-based queries
|
||||
allFields,
|
||||
});
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('should work correctly when filtered', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
onFilterField: (field: DataViewField) => field.name.startsWith('@'),
|
||||
})
|
||||
);
|
||||
const props: GroupedFieldsParams<DataViewField> = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: allFieldsIncludingUnmapped,
|
||||
services: mockedServices,
|
||||
};
|
||||
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
|
||||
initialProps: props,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
let fieldGroups = result.current.fieldGroups;
|
||||
const scrollToTopResetCounter1 = result.current.scrollToTopResetCounter;
|
||||
|
||||
expect(
|
||||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
(key) =>
|
||||
`${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}-${
|
||||
fieldGroups![key as FieldsGroupNames]?.fieldCount
|
||||
}`
|
||||
)
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'AvailableFields-2',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-0',
|
||||
'SpecialFields-0-0',
|
||||
'SelectedFields-0-0',
|
||||
'PopularFields-0-0',
|
||||
'AvailableFields-25-25',
|
||||
'UnmappedFields-28-28',
|
||||
'EmptyFields-0-0',
|
||||
'MetaFields-3-3',
|
||||
]);
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
onFilterField: (field: DataViewField) => field.name.startsWith('@'),
|
||||
});
|
||||
|
||||
fieldGroups = result.current.fieldGroups;
|
||||
|
||||
expect(
|
||||
Object.keys(fieldGroups!).map(
|
||||
(key) =>
|
||||
`${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}-${
|
||||
fieldGroups![key as FieldsGroupNames]?.fieldCount
|
||||
}`
|
||||
)
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0-0',
|
||||
'SelectedFields-0-0',
|
||||
'PopularFields-0-0',
|
||||
'AvailableFields-2-25',
|
||||
'UnmappedFields-2-28',
|
||||
'EmptyFields-0-0',
|
||||
'MetaFields-0-3',
|
||||
]);
|
||||
|
||||
expect(result.current.scrollToTopResetCounter).not.toBe(scrollToTopResetCounter1);
|
||||
});
|
||||
|
||||
it('should not change the scroll position if fields list is extended', async () => {
|
||||
const props: GroupedFieldsParams<DataViewField> = {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
};
|
||||
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
|
||||
initialProps: props,
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const scrollToTopResetCounter1 = result.current.scrollToTopResetCounter;
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
allFields: allFieldsIncludingUnmapped,
|
||||
});
|
||||
|
||||
expect(result.current.scrollToTopResetCounter).toBe(scrollToTopResetCounter1);
|
||||
});
|
||||
|
||||
it('should work correctly when custom unsupported fields are skipped', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
onSupportedFieldFilter: (field: DataViewField) => field.aggregatable,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
|
@ -139,22 +258,24 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-23',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work correctly when selected fields are present', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
onSelectedFieldFilter: (field: DataViewField) =>
|
||||
['bytes', 'extension', '_id', '@timestamp'].includes(field.name),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
|
@ -167,20 +288,22 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-4',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-25',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work correctly for text-based queries (no data view)', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
const { result } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: null,
|
||||
allFields,
|
||||
allFields: allFieldsIncludingUnmapped,
|
||||
services: mockedServices,
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
|
||||
|
@ -188,24 +311,36 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
)
|
||||
).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-28', 'MetaFields-0']);
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-56', // even unmapped fields fall into Available
|
||||
'UnmappedFields-0',
|
||||
'MetaFields-0',
|
||||
]);
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('should work correctly when details are overwritten', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useGroupedFields({
|
||||
const onOverrideFieldGroupDetails: GroupedFieldsParams<DataViewField>['onOverrideFieldGroupDetails'] =
|
||||
jest.fn((groupName) => {
|
||||
if (groupName === FieldsGroupNames.SelectedFields) {
|
||||
return {
|
||||
helpText: 'test',
|
||||
};
|
||||
}
|
||||
});
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
services: mockedServices,
|
||||
onOverrideFieldGroupDetails: (groupName) => {
|
||||
if (groupName === FieldsGroupNames.SelectedFields) {
|
||||
return {
|
||||
helpText: 'test',
|
||||
};
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
onOverrideFieldGroupDetails,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
|
@ -213,6 +348,7 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
|
||||
expect(fieldGroups[FieldsGroupNames.SelectedFields]?.helpText).toBe('test');
|
||||
expect(fieldGroups[FieldsGroupNames.AvailableFields]?.helpText).not.toBe('test');
|
||||
expect(onOverrideFieldGroupDetails).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should work correctly when changing a data view and existence info is available only for one of them', async () => {
|
||||
|
@ -248,11 +384,16 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-2',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-23',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
|
||||
rerender({
|
||||
...props,
|
||||
dataViewId: anotherDataView.id!,
|
||||
|
@ -267,6 +408,133 @@ describe('UnifiedFieldList useGroupedFields()', () => {
|
|||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
)
|
||||
).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-8', 'MetaFields-0']);
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-8',
|
||||
'UnmappedFields-0',
|
||||
'MetaFields-0',
|
||||
]);
|
||||
|
||||
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
|
||||
expect(result.current.fieldsExistInIndex).toBe(true);
|
||||
});
|
||||
|
||||
it('should work correctly when popular fields limit is present', async () => {
|
||||
// `bytes` is popular, but we are skipping it here to test that it would not be shown under Popular and Available
|
||||
const onSupportedFieldFilter = jest.fn((field) => field.name !== 'bytes');
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
popularFieldsLimit: 10,
|
||||
services: mockedServices,
|
||||
onSupportedFieldFilter,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
|
||||
expect(
|
||||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
)
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-3',
|
||||
'AvailableFields-24',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
|
||||
expect(fieldGroups.PopularFields?.fields.map((field) => field.name).join(',')).toBe(
|
||||
'@timestamp,time,ssl'
|
||||
);
|
||||
});
|
||||
|
||||
it('should work correctly when global filters are set', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: [],
|
||||
isAffectedByGlobalFilter: true,
|
||||
services: mockedServices,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
expect(fieldGroups).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should work correctly and show unmapped fields separately', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields: allFieldsIncludingUnmapped,
|
||||
services: mockedServices,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
|
||||
expect(
|
||||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
)
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-0',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-25',
|
||||
'UnmappedFields-28',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should work correctly when custom selected fields are provided', async () => {
|
||||
const customSortedFields = [
|
||||
allFieldsIncludingUnmapped[allFieldsIncludingUnmapped.length - 1],
|
||||
allFields[2],
|
||||
allFields[0],
|
||||
];
|
||||
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
|
||||
initialProps: {
|
||||
dataViewId: dataView.id!,
|
||||
allFields,
|
||||
sortedSelectedFields: customSortedFields,
|
||||
services: mockedServices,
|
||||
},
|
||||
});
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
const fieldGroups = result.current.fieldGroups;
|
||||
|
||||
expect(
|
||||
Object.keys(fieldGroups!).map(
|
||||
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
|
||||
)
|
||||
).toStrictEqual([
|
||||
'SpecialFields-0',
|
||||
'SelectedFields-3',
|
||||
'PopularFields-0',
|
||||
'AvailableFields-25',
|
||||
'UnmappedFields-0',
|
||||
'EmptyFields-0',
|
||||
'MetaFields-3',
|
||||
]);
|
||||
|
||||
expect(fieldGroups.SelectedFields?.fields).toBe(customSortedFields);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,16 +17,20 @@ import {
|
|||
type FieldsGroup,
|
||||
type FieldListItem,
|
||||
FieldsGroupNames,
|
||||
ExistenceFetchStatus,
|
||||
} from '../types';
|
||||
import { type ExistingFieldsReader } from './use_existing_fields';
|
||||
|
||||
export interface GroupedFieldsParams<T extends FieldListItem> {
|
||||
dataViewId: string | null; // `null` is for text-based queries
|
||||
allFields: T[];
|
||||
allFields: T[] | null; // `null` is for loading indicator
|
||||
services: {
|
||||
dataViews: DataViewsContract;
|
||||
};
|
||||
fieldsExistenceReader?: ExistingFieldsReader;
|
||||
fieldsExistenceReader?: ExistingFieldsReader; // use `undefined` for text-based queries
|
||||
isAffectedByGlobalFilter?: boolean;
|
||||
popularFieldsLimit?: number;
|
||||
sortedSelectedFields?: T[];
|
||||
onOverrideFieldGroupDetails?: (
|
||||
groupName: FieldsGroupNames
|
||||
) => Partial<FieldsGroupDetails> | undefined | null;
|
||||
|
@ -37,6 +41,9 @@ export interface GroupedFieldsParams<T extends FieldListItem> {
|
|||
|
||||
export interface GroupedFieldsResult<T extends FieldListItem> {
|
||||
fieldGroups: FieldListGroups<T>;
|
||||
scrollToTopResetCounter: number;
|
||||
fieldsExistenceStatus: ExistenceFetchStatus;
|
||||
fieldsExistInIndex: boolean;
|
||||
}
|
||||
|
||||
export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
||||
|
@ -44,12 +51,16 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
allFields,
|
||||
services,
|
||||
fieldsExistenceReader,
|
||||
isAffectedByGlobalFilter = false,
|
||||
popularFieldsLimit,
|
||||
sortedSelectedFields,
|
||||
onOverrideFieldGroupDetails,
|
||||
onSupportedFieldFilter,
|
||||
onSelectedFieldFilter,
|
||||
onFilterField,
|
||||
}: GroupedFieldsParams<T>): GroupedFieldsResult<T> {
|
||||
const [dataView, setDataView] = useState<DataView | null>(null);
|
||||
const isAffectedByTimeFilter = Boolean(dataView?.timeFieldName);
|
||||
const fieldsExistenceInfoUnavailable: boolean = dataViewId
|
||||
? fieldsExistenceReader?.isFieldsExistenceInfoUnavailable(dataViewId) ?? false
|
||||
: true;
|
||||
|
@ -68,33 +79,59 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
// if field existence information changed, reload the data view too
|
||||
}, [dataViewId, services.dataViews, setDataView, hasFieldDataHandler]);
|
||||
|
||||
// important when switching from a known dataViewId to no data view (like in text-based queries)
|
||||
useEffect(() => {
|
||||
if (dataView && !dataViewId) {
|
||||
setDataView(null);
|
||||
}
|
||||
}, [dataView, setDataView, dataViewId]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const scrollToTopResetCounter: number = useMemo(() => Date.now(), [dataViewId, onFilterField]);
|
||||
|
||||
const unfilteredFieldGroups: FieldListGroups<T> = useMemo(() => {
|
||||
const containsData = (field: T) => {
|
||||
if (!dataViewId || !dataView) {
|
||||
return true;
|
||||
}
|
||||
const overallField = dataView.getFieldByName?.(field.name);
|
||||
return Boolean(overallField && hasFieldDataHandler(dataViewId, overallField.name));
|
||||
return dataViewId ? hasFieldDataHandler(dataViewId, field.name) : true;
|
||||
};
|
||||
|
||||
const fields = allFields || [];
|
||||
const allSupportedTypesFields = onSupportedFieldFilter
|
||||
? fields.filter(onSupportedFieldFilter)
|
||||
: fields;
|
||||
const sortedFields = [...allSupportedTypesFields].sort(sortFields);
|
||||
const selectedFields = sortedSelectedFields || [];
|
||||
const sortedFields = [...(allFields || [])].sort(sortFields);
|
||||
const groupedFields = {
|
||||
...getDefaultFieldGroups(),
|
||||
...groupBy(sortedFields, (field) => {
|
||||
if (!sortedSelectedFields && onSelectedFieldFilter && onSelectedFieldFilter(field)) {
|
||||
selectedFields.push(field);
|
||||
}
|
||||
if (onSupportedFieldFilter && !onSupportedFieldFilter(field)) {
|
||||
return 'skippedFields';
|
||||
}
|
||||
if (field.type === 'document') {
|
||||
return 'specialFields';
|
||||
} else if (dataView?.metaFields?.includes(field.name)) {
|
||||
}
|
||||
if (dataView?.metaFields?.includes(field.name)) {
|
||||
return 'metaFields';
|
||||
} else if (containsData(field)) {
|
||||
}
|
||||
if (dataView?.getFieldByName && !dataView.getFieldByName(field.name)) {
|
||||
return 'unmappedFields';
|
||||
}
|
||||
if (containsData(field) || fieldsExistenceInfoUnavailable) {
|
||||
return 'availableFields';
|
||||
} else return 'emptyFields';
|
||||
}
|
||||
return 'emptyFields';
|
||||
}),
|
||||
};
|
||||
const selectedFields = onSelectedFieldFilter ? sortedFields.filter(onSelectedFieldFilter) : [];
|
||||
|
||||
const popularFields = popularFieldsLimit
|
||||
? sortedFields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.count &&
|
||||
field.type !== '_source' &&
|
||||
(!onSupportedFieldFilter || onSupportedFieldFilter(field))
|
||||
)
|
||||
.sort((a: T, b: T) => (b.count || 0) - (a.count || 0)) // sort by popularity score
|
||||
.slice(0, popularFieldsLimit)
|
||||
: [];
|
||||
|
||||
let fieldGroupDefinitions: FieldListGroups<T> = {
|
||||
SpecialFields: {
|
||||
|
@ -115,8 +152,25 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
title: i18n.translate('unifiedFieldList.useGroupedFields.selectedFieldsLabel', {
|
||||
defaultMessage: 'Selected fields',
|
||||
}),
|
||||
isAffectedByGlobalFilter: false,
|
||||
isAffectedByTimeFilter: true,
|
||||
isAffectedByGlobalFilter,
|
||||
isAffectedByTimeFilter,
|
||||
hideDetails: false,
|
||||
hideIfEmpty: true,
|
||||
},
|
||||
PopularFields: {
|
||||
fields: popularFields,
|
||||
fieldCount: popularFields.length,
|
||||
isInitiallyOpen: true,
|
||||
showInAccordion: true,
|
||||
title: i18n.translate('unifiedFieldList.useGroupedFields.popularFieldsLabel', {
|
||||
defaultMessage: 'Popular fields',
|
||||
}),
|
||||
helpText: i18n.translate('unifiedFieldList.useGroupedFields.popularFieldsLabelHelp', {
|
||||
defaultMessage:
|
||||
'Fields that your organization frequently uses, from most to least popular.',
|
||||
}),
|
||||
isAffectedByGlobalFilter,
|
||||
isAffectedByTimeFilter,
|
||||
hideDetails: false,
|
||||
hideIfEmpty: true,
|
||||
},
|
||||
|
@ -133,8 +187,8 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
: i18n.translate('unifiedFieldList.useGroupedFields.availableFieldsLabel', {
|
||||
defaultMessage: 'Available fields',
|
||||
}),
|
||||
isAffectedByGlobalFilter: false,
|
||||
isAffectedByTimeFilter: true,
|
||||
isAffectedByGlobalFilter,
|
||||
isAffectedByTimeFilter,
|
||||
// Show details on timeout but not failure
|
||||
// hideDetails: fieldsExistenceInfoUnavailable && !existenceFetchTimeout, // TODO: is this check still necessary?
|
||||
hideDetails: fieldsExistenceInfoUnavailable,
|
||||
|
@ -145,6 +199,22 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
}
|
||||
),
|
||||
},
|
||||
UnmappedFields: {
|
||||
fields: groupedFields.unmappedFields,
|
||||
fieldCount: groupedFields.unmappedFields.length,
|
||||
isAffectedByGlobalFilter,
|
||||
isAffectedByTimeFilter,
|
||||
isInitiallyOpen: false,
|
||||
showInAccordion: true,
|
||||
hideDetails: false,
|
||||
hideIfEmpty: true,
|
||||
title: i18n.translate('unifiedFieldList.useGroupedFields.unmappedFieldsLabel', {
|
||||
defaultMessage: 'Unmapped fields',
|
||||
}),
|
||||
helpText: i18n.translate('unifiedFieldList.useGroupedFields.unmappedFieldsLabelHelp', {
|
||||
defaultMessage: "Fields that aren't explicitly mapped to a field data type.",
|
||||
}),
|
||||
},
|
||||
EmptyFields: {
|
||||
fields: groupedFields.emptyFields,
|
||||
fieldCount: groupedFields.emptyFields.length,
|
||||
|
@ -157,15 +227,15 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
title: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabel', {
|
||||
defaultMessage: 'Empty fields',
|
||||
}),
|
||||
helpText: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp', {
|
||||
defaultMessage: "Fields that don't have any values based on your filters.",
|
||||
}),
|
||||
defaultNoFieldsMessage: i18n.translate(
|
||||
'unifiedFieldList.useGroupedFields.noEmptyDataLabel',
|
||||
{
|
||||
defaultMessage: `There are no empty fields.`,
|
||||
}
|
||||
),
|
||||
helpText: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp', {
|
||||
defaultMessage: 'Empty fields did not contain any values based on your filters.',
|
||||
}),
|
||||
},
|
||||
MetaFields: {
|
||||
fields: groupedFields.metaFields,
|
||||
|
@ -220,6 +290,10 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
dataViewId,
|
||||
hasFieldDataHandler,
|
||||
fieldsExistenceInfoUnavailable,
|
||||
isAffectedByGlobalFilter,
|
||||
isAffectedByTimeFilter,
|
||||
popularFieldsLimit,
|
||||
sortedSelectedFields,
|
||||
]);
|
||||
|
||||
const fieldGroups: FieldListGroups<T> = useMemo(() => {
|
||||
|
@ -235,22 +309,39 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
|
|||
) as FieldListGroups<T>;
|
||||
}, [unfilteredFieldGroups, onFilterField]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
const hasDataLoaded = Boolean(allFields);
|
||||
const allFieldsLength = allFields?.length;
|
||||
|
||||
const fieldsExistInIndex = useMemo(() => {
|
||||
return dataViewId ? Boolean(allFieldsLength) : true;
|
||||
}, [dataViewId, allFieldsLength]);
|
||||
|
||||
const fieldsExistenceStatus = useMemo(() => {
|
||||
if (!hasDataLoaded) {
|
||||
return ExistenceFetchStatus.unknown; // to show loading indicator in the list
|
||||
}
|
||||
if (!dataViewId || !fieldsExistenceReader) {
|
||||
// ex. for text-based queries
|
||||
return ExistenceFetchStatus.succeeded;
|
||||
}
|
||||
return fieldsExistenceReader.getFieldsExistenceStatus(dataViewId);
|
||||
}, [dataViewId, hasDataLoaded, fieldsExistenceReader]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
fieldGroups,
|
||||
}),
|
||||
[fieldGroups]
|
||||
);
|
||||
scrollToTopResetCounter,
|
||||
fieldsExistInIndex,
|
||||
fieldsExistenceStatus,
|
||||
};
|
||||
}, [fieldGroups, scrollToTopResetCounter, fieldsExistInIndex, fieldsExistenceStatus]);
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator(undefined, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
function sortFields<T extends FieldListItem>(fieldA: T, fieldB: T) {
|
||||
return (fieldA.displayName || fieldA.name).localeCompare(
|
||||
fieldB.displayName || fieldB.name,
|
||||
undefined,
|
||||
{
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
return collator.compare(fieldA.displayName || fieldA.name, fieldB.displayName || fieldB.name);
|
||||
}
|
||||
|
||||
function hasFieldDataByDefault(): boolean {
|
||||
|
@ -263,5 +354,7 @@ function getDefaultFieldGroups() {
|
|||
availableFields: [],
|
||||
emptyFields: [],
|
||||
metaFields: [],
|
||||
unmappedFields: [],
|
||||
skippedFields: [],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
|
||||
import { getResolvedDateRange } from '../utils/get_resolved_date_range';
|
||||
|
||||
/**
|
||||
* Hook params
|
||||
|
@ -23,32 +24,68 @@ export interface QuerySubscriberParams {
|
|||
export interface QuerySubscriberResult {
|
||||
query: Query | AggregateQuery | undefined;
|
||||
filters: Filter[] | undefined;
|
||||
fromDate: string | undefined;
|
||||
toDate: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memorizes current query and filters
|
||||
* Memorizes current query, filters and absolute date range
|
||||
* @param data
|
||||
* @public
|
||||
*/
|
||||
export const useQuerySubscriber = ({ data }: QuerySubscriberParams) => {
|
||||
const timefilter = data.query.timefilter.timefilter;
|
||||
const [result, setResult] = useState<QuerySubscriberResult>(() => {
|
||||
const state = data.query.getState();
|
||||
const dateRange = getResolvedDateRange(timefilter);
|
||||
return {
|
||||
query: state?.query,
|
||||
filters: state?.filters,
|
||||
fromDate: dateRange.fromDate,
|
||||
toDate: dateRange.toDate,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = data.query.state$.subscribe(({ state }) => {
|
||||
const subscription = data.search.session.state$.subscribe((sessionState) => {
|
||||
const dateRange = getResolvedDateRange(timefilter);
|
||||
setResult((prevState) => ({
|
||||
...prevState,
|
||||
query: state.query,
|
||||
filters: state.filters,
|
||||
fromDate: dateRange.fromDate,
|
||||
toDate: dateRange.toDate,
|
||||
}));
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setResult, timefilter, data.search.session.state$]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = data.query.state$.subscribe(({ state, changes }) => {
|
||||
if (changes.query || changes.filters) {
|
||||
setResult((prevState) => ({
|
||||
...prevState,
|
||||
query: state.query,
|
||||
filters: state.filters,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setResult, data.query.state$]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if query result is ready to be used
|
||||
* @param result
|
||||
* @public
|
||||
*/
|
||||
export const hasQuerySubscriberData = (
|
||||
result: QuerySubscriberResult
|
||||
): result is {
|
||||
query: Query | AggregateQuery;
|
||||
filters: Filter[];
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
} => Boolean(result.query && result.filters && result.fromDate && result.toDate);
|
||||
|
|
|
@ -76,6 +76,7 @@ export {
|
|||
|
||||
export {
|
||||
useQuerySubscriber,
|
||||
hasQuerySubscriberData,
|
||||
type QuerySubscriberResult,
|
||||
type QuerySubscriberParams,
|
||||
} from './hooks/use_query_subscriber';
|
||||
|
|
|
@ -29,14 +29,17 @@ export interface FieldListItem {
|
|||
name: DataViewField['name'];
|
||||
type?: DataViewField['type'];
|
||||
displayName?: DataViewField['displayName'];
|
||||
count?: DataViewField['count'];
|
||||
}
|
||||
|
||||
export enum FieldsGroupNames {
|
||||
SpecialFields = 'SpecialFields',
|
||||
SelectedFields = 'SelectedFields',
|
||||
PopularFields = 'PopularFields',
|
||||
AvailableFields = 'AvailableFields',
|
||||
EmptyFields = 'EmptyFields',
|
||||
MetaFields = 'MetaFields',
|
||||
UnmappedFields = 'UnmappedFields',
|
||||
}
|
||||
|
||||
export interface FieldsGroupDetails {
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { type TimefilterContract } from '@kbn/data-plugin/public';
|
||||
|
||||
/**
|
||||
* Get resolved time range by using now provider
|
||||
* @param timefilter
|
||||
*/
|
||||
export const getResolvedDateRange = (timefilter: TimefilterContract) => {
|
||||
const { from, to } = timefilter.getTime();
|
||||
const { min, max } = timefilter.calculateBounds({
|
||||
from,
|
||||
to,
|
||||
});
|
||||
return { fromDate: min?.toISOString() || from, toDate: max?.toISOString() || to };
|
||||
};
|
|
@ -20,22 +20,31 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
'unifiedSearch',
|
||||
]);
|
||||
const testSubjects = getService('testSubjects');
|
||||
const find = getService('find');
|
||||
const browser = getService('browser');
|
||||
const monacoEditor = getService('monacoEditor');
|
||||
const filterBar = getService('filterBar');
|
||||
const fieldEditor = getService('fieldEditor');
|
||||
|
||||
describe('discover sidebar', function describeIndexTests() {
|
||||
before(async function () {
|
||||
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
|
||||
await kibanaServer.uiSettings.replace({
|
||||
defaultIndex: 'logstash-*',
|
||||
});
|
||||
await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings();
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
afterEach(async () => {
|
||||
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
await kibanaServer.uiSettings.replace({});
|
||||
});
|
||||
|
||||
describe('field filtering', function () {
|
||||
|
@ -107,5 +116,449 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await testSubjects.existOrFail('discover-sidebar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renders field groups', function () {
|
||||
it('should show field list groups excluding subfields', async function () {
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
|
||||
// Initial Available fields
|
||||
const expectedInitialAvailableFields =
|
||||
'@message, @tags, @timestamp, agent, bytes, clientip, extension, geo.coordinates, geo.dest, geo.src, geo.srcdest, headings, host, id, index, ip, links, machine.os, machine.ram, machine.ram_range, memory, meta.char, meta.related, meta.user.firstname, meta.user.lastname, nestedField.child, phpmemory, referer, relatedContent.article:modified_time, relatedContent.article:published_time, relatedContent.article:section, relatedContent.article:tag, relatedContent.og:description, relatedContent.og:image, relatedContent.og:image:height, relatedContent.og:image:width, relatedContent.og:site_name, relatedContent.og:title, relatedContent.og:type, relatedContent.og:url, relatedContent.twitter:card, relatedContent.twitter:description, relatedContent.twitter:image, relatedContent.twitter:site, relatedContent.twitter:title, relatedContent.url, request, response, spaces, type';
|
||||
let availableFields = await PageObjects.discover.getSidebarSectionFieldNames('available');
|
||||
expect(availableFields.length).to.be(50);
|
||||
expect(availableFields.join(', ')).to.be(expectedInitialAvailableFields);
|
||||
|
||||
// Available fields after scrolling down
|
||||
const emptySectionButton = await find.byCssSelector(
|
||||
PageObjects.discover.getSidebarSectionSelector('empty', true)
|
||||
);
|
||||
await emptySectionButton.scrollIntoViewIfNecessary();
|
||||
availableFields = await PageObjects.discover.getSidebarSectionFieldNames('available');
|
||||
expect(availableFields.length).to.be(53);
|
||||
expect(availableFields.join(', ')).to.be(
|
||||
`${expectedInitialAvailableFields}, url, utc_time, xss`
|
||||
);
|
||||
|
||||
// Expand Empty section
|
||||
await PageObjects.discover.toggleSidebarSection('empty');
|
||||
expect((await PageObjects.discover.getSidebarSectionFieldNames('empty')).join(', ')).to.be(
|
||||
''
|
||||
);
|
||||
|
||||
// Expand Meta section
|
||||
await PageObjects.discover.toggleSidebarSection('meta');
|
||||
expect((await PageObjects.discover.getSidebarSectionFieldNames('meta')).join(', ')).to.be(
|
||||
'_id, _index, _score'
|
||||
);
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show field list groups excluding subfields when searched from source', async function () {
|
||||
await kibanaServer.uiSettings.update({ 'discover:searchFieldsFromSource': true });
|
||||
await browser.refresh();
|
||||
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
|
||||
// Initial Available fields
|
||||
let availableFields = await PageObjects.discover.getSidebarSectionFieldNames('available');
|
||||
expect(availableFields.length).to.be(50);
|
||||
expect(
|
||||
availableFields
|
||||
.join(', ')
|
||||
.startsWith(
|
||||
'@message, @tags, @timestamp, agent, bytes, clientip, extension, geo.coordinates'
|
||||
)
|
||||
).to.be(true);
|
||||
|
||||
// Available fields after scrolling down
|
||||
const emptySectionButton = await find.byCssSelector(
|
||||
PageObjects.discover.getSidebarSectionSelector('empty', true)
|
||||
);
|
||||
await emptySectionButton.scrollIntoViewIfNecessary();
|
||||
availableFields = await PageObjects.discover.getSidebarSectionFieldNames('available');
|
||||
expect(availableFields.length).to.be(53);
|
||||
|
||||
// Expand Empty section
|
||||
await PageObjects.discover.toggleSidebarSection('empty');
|
||||
expect((await PageObjects.discover.getSidebarSectionFieldNames('empty')).join(', ')).to.be(
|
||||
''
|
||||
);
|
||||
|
||||
// Expand Meta section
|
||||
await PageObjects.discover.toggleSidebarSection('meta');
|
||||
expect((await PageObjects.discover.getSidebarSectionFieldNames('meta')).join(', ')).to.be(
|
||||
'_id, _index, _score'
|
||||
);
|
||||
|
||||
// Expand Unmapped section
|
||||
await PageObjects.discover.toggleSidebarSection('unmapped');
|
||||
expect(
|
||||
(await PageObjects.discover.getSidebarSectionFieldNames('unmapped')).join(', ')
|
||||
).to.be('relatedContent');
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 1 unmapped field. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show selected and popular fields', async function () {
|
||||
await PageObjects.discover.clickFieldListItemAdd('extension');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
await PageObjects.discover.clickFieldListItemAdd('@message');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
|
||||
expect(
|
||||
(await PageObjects.discover.getSidebarSectionFieldNames('selected')).join(', ')
|
||||
).to.be('extension, @message');
|
||||
|
||||
const availableFields = await PageObjects.discover.getSidebarSectionFieldNames('available');
|
||||
expect(availableFields.includes('extension')).to.be(true);
|
||||
expect(availableFields.includes('@message')).to.be(true);
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'2 selected fields. 2 popular fields. 53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.clickFieldListItemRemove('@message');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
|
||||
await PageObjects.discover.clickFieldListItemAdd('_id');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
await PageObjects.discover.clickFieldListItemAdd('@message');
|
||||
await PageObjects.discover.waitUntilSearchingHasFinished();
|
||||
|
||||
expect(
|
||||
(await PageObjects.discover.getSidebarSectionFieldNames('selected')).join(', ')
|
||||
).to.be('extension, _id, @message');
|
||||
|
||||
expect(
|
||||
(await PageObjects.discover.getSidebarSectionFieldNames('popular')).join(', ')
|
||||
).to.be('@message, _id, extension');
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'3 selected fields. 3 popular fields. 53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should show selected and available fields in text-based mode', async function () {
|
||||
await kibanaServer.uiSettings.update({ 'discover:enableSql': true });
|
||||
await browser.refresh();
|
||||
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectTextBaseLang('SQL');
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'50 selected fields. 51 available fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.clickFieldListItemRemove('extension');
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'49 selected fields. 51 available fields.'
|
||||
);
|
||||
|
||||
const testQuery = `SELECT "@tags", geo.dest, count(*) occurred FROM "logstash-*"
|
||||
GROUP BY "@tags", geo.dest
|
||||
HAVING occurred > 20
|
||||
ORDER BY occurred DESC`;
|
||||
|
||||
await monacoEditor.setCodeEditorValue(testQuery);
|
||||
await testSubjects.click('querySubmitButton');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'3 selected fields. 3 available fields.'
|
||||
);
|
||||
expect(
|
||||
(await PageObjects.discover.getSidebarSectionFieldNames('selected')).join(', ')
|
||||
).to.be('@tags, geo.dest, occurred');
|
||||
|
||||
await PageObjects.unifiedSearch.switchDataView(
|
||||
'discover-dataView-switch-link',
|
||||
'logstash-*',
|
||||
true
|
||||
);
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'1 popular field. 53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should work correctly for a data view for a missing index', async function () {
|
||||
// but we are skipping importing the index itself
|
||||
await kibanaServer.importExport.load(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
await browser.refresh();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('with-timefield');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'0 available fields. 0 meta fields.'
|
||||
);
|
||||
await testSubjects.existOrFail(
|
||||
`${PageObjects.discover.getSidebarSectionSelector('available')}-fetchWarning`
|
||||
);
|
||||
await testSubjects.existOrFail(
|
||||
`${PageObjects.discover.getSidebarSectionSelector(
|
||||
'available'
|
||||
)}NoFieldsCallout-noFieldsExist`
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('logstash-*');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
await kibanaServer.importExport.unload(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
});
|
||||
|
||||
it('should work correctly when switching data views', async function () {
|
||||
await esArchiver.loadIfNeeded(
|
||||
'test/functional/fixtures/es_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
await kibanaServer.importExport.load(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
|
||||
await browser.refresh();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('without-timefield');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'6 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('with-timefield');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'0 available fields. 7 empty fields. 3 meta fields.'
|
||||
);
|
||||
await testSubjects.existOrFail(
|
||||
`${PageObjects.discover.getSidebarSectionSelector(
|
||||
'available'
|
||||
)}NoFieldsCallout-noFieldsMatch`
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('logstash-*');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await kibanaServer.importExport.unload(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
|
||||
await esArchiver.unload(
|
||||
'test/functional/fixtures/es_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
});
|
||||
|
||||
it('should work when filters change', async () => {
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.clickFieldListItem('extension');
|
||||
expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be(
|
||||
'jpg\n65.0%\ncss\n15.4%\npng\n9.8%\ngif\n6.6%\nphp\n3.2%'
|
||||
);
|
||||
|
||||
await filterBar.addFilter('extension', 'is', 'jpg');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
// check that the filter was passed down to the sidebar
|
||||
await PageObjects.discover.clickFieldListItem('extension');
|
||||
expect(await testSubjects.getVisibleText('dscFieldStats-topValues')).to.be('jpg\n100%');
|
||||
});
|
||||
|
||||
it('should work for many fields', async () => {
|
||||
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/many_fields');
|
||||
await kibanaServer.importExport.load(
|
||||
'test/functional/fixtures/kbn_archiver/many_fields_data_view'
|
||||
);
|
||||
|
||||
await browser.refresh();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('indices-stats*');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'6873 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('logstash-*');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await kibanaServer.importExport.unload(
|
||||
'test/functional/fixtures/kbn_archiver/many_fields_data_view'
|
||||
);
|
||||
await esArchiver.unload('test/functional/fixtures/es_archiver/many_fields');
|
||||
});
|
||||
|
||||
it('should work with ad-hoc data views and runtime fields', async () => {
|
||||
await PageObjects.discover.createAdHocDataView('logstash', true);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.addRuntimeField(
|
||||
'_bytes-runtimefield',
|
||||
`emit((doc["bytes"].value * 2).toString())`
|
||||
);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'54 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
let allFields = await PageObjects.discover.getAllFieldNames();
|
||||
expect(allFields.includes('_bytes-runtimefield')).to.be(true);
|
||||
|
||||
await PageObjects.discover.editField('_bytes-runtimefield');
|
||||
await fieldEditor.enableCustomLabel();
|
||||
await fieldEditor.setCustomLabel('_bytes-runtimefield2');
|
||||
await fieldEditor.save();
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'54 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
allFields = await PageObjects.discover.getAllFieldNames();
|
||||
expect(allFields.includes('_bytes-runtimefield2')).to.be(true);
|
||||
expect(allFields.includes('_bytes-runtimefield')).to.be(false);
|
||||
|
||||
await PageObjects.discover.removeField('_bytes-runtimefield');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
allFields = await PageObjects.discover.getAllFieldNames();
|
||||
expect(allFields.includes('_bytes-runtimefield2')).to.be(false);
|
||||
expect(allFields.includes('_bytes-runtimefield')).to.be(false);
|
||||
});
|
||||
|
||||
it('should work correctly when time range is updated', async function () {
|
||||
await esArchiver.loadIfNeeded(
|
||||
'test/functional/fixtures/es_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
await kibanaServer.importExport.load(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
|
||||
await browser.refresh();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'53 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await PageObjects.discover.selectIndexPattern('with-timefield');
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'0 available fields. 7 empty fields. 3 meta fields.'
|
||||
);
|
||||
await testSubjects.existOrFail(
|
||||
`${PageObjects.discover.getSidebarSectionSelector(
|
||||
'available'
|
||||
)}NoFieldsCallout-noFieldsMatch`
|
||||
);
|
||||
|
||||
await PageObjects.timePicker.setAbsoluteRange(
|
||||
'Sep 21, 2019 @ 00:00:00.000',
|
||||
'Sep 23, 2019 @ 00:00:00.000'
|
||||
);
|
||||
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
await PageObjects.discover.waitUntilSidebarHasLoaded();
|
||||
|
||||
expect(await PageObjects.discover.getSidebarAriaDescription()).to.be(
|
||||
'7 available fields. 0 empty fields. 3 meta fields.'
|
||||
);
|
||||
|
||||
await kibanaServer.importExport.unload(
|
||||
'test/functional/fixtures/kbn_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
|
||||
await esArchiver.unload(
|
||||
'test/functional/fixtures/es_archiver/index_pattern_without_timefield'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -51,6 +51,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
after(async () => {
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/logstash_functional');
|
||||
});
|
||||
|
||||
|
|
|
@ -22,8 +22,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await kibanaServer.savedObjects.clean({ types: ['search', 'index-pattern'] });
|
||||
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/unmapped_fields');
|
||||
await security.testUser.setRoles(['kibana_admin', 'test-index-unmapped-fields']);
|
||||
const fromTime = 'Jan 20, 2021 @ 00:00:00.000';
|
||||
const toTime = 'Jan 25, 2021 @ 00:00:00.000';
|
||||
const fromTime = '2021-01-20T00:00:00.000Z';
|
||||
const toTime = '2021-01-25T00:00:00.000Z';
|
||||
|
||||
await kibanaServer.uiSettings.replace({
|
||||
defaultIndex: 'test-index-unmapped-fields',
|
||||
|
@ -48,11 +48,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be(expectedHitCount);
|
||||
});
|
||||
const allFields = await PageObjects.discover.getAllFieldNames();
|
||||
let allFields = await PageObjects.discover.getAllFieldNames();
|
||||
// message is a mapped field
|
||||
expect(allFields.includes('message')).to.be(true);
|
||||
// sender is not a mapped field
|
||||
expect(allFields.includes('sender')).to.be(true);
|
||||
expect(allFields.includes('sender')).to.be(false);
|
||||
|
||||
await PageObjects.discover.toggleSidebarSection('unmapped');
|
||||
|
||||
allFields = await PageObjects.discover.getAllFieldNames();
|
||||
expect(allFields.includes('sender')).to.be(true); // now visible under Unmapped section
|
||||
|
||||
await PageObjects.discover.toggleSidebarSection('unmapped');
|
||||
});
|
||||
|
||||
it('unmapped fields exist on an existing saved search', async () => {
|
||||
|
@ -61,10 +68,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await retry.try(async function () {
|
||||
expect(await PageObjects.discover.getHitCount()).to.be(expectedHitCount);
|
||||
});
|
||||
const allFields = await PageObjects.discover.getAllFieldNames();
|
||||
let allFields = await PageObjects.discover.getAllFieldNames();
|
||||
expect(allFields.includes('message')).to.be(true);
|
||||
expect(allFields.includes('sender')).to.be(false);
|
||||
expect(allFields.includes('receiver')).to.be(false);
|
||||
|
||||
await PageObjects.discover.toggleSidebarSection('unmapped');
|
||||
|
||||
allFields = await PageObjects.discover.getAllFieldNames();
|
||||
|
||||
// now visible under Unmapped section
|
||||
expect(allFields.includes('sender')).to.be(true);
|
||||
expect(allFields.includes('receiver')).to.be(true);
|
||||
|
||||
await PageObjects.discover.toggleSidebarSection('unmapped');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -25,6 +25,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
defaultIndex: 'logstash-*',
|
||||
};
|
||||
|
||||
const savedSearchName = 'saved-search-with-on-page-load';
|
||||
|
||||
const initSearchOnPageLoad = async (searchOnPageLoad: boolean) => {
|
||||
await kibanaServer.uiSettings.replace({ 'discover:searchOnPageLoad': searchOnPageLoad });
|
||||
await PageObjects.common.navigateToApp('discover');
|
||||
|
@ -60,6 +62,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
await esArchiver.unload('test/functional/fixtures/es_archiver/date_nested');
|
||||
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
|
||||
await kibanaServer.uiSettings.replace(defaultSettings);
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
});
|
||||
|
||||
describe(`when it's false`, () => {
|
||||
|
@ -68,6 +71,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
it('should not fetch data from ES initially', async function () {
|
||||
expect(await testSubjects.exists(refreshButtonSelector)).to.be(true);
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
});
|
||||
|
||||
it('should not fetch on indexPattern change', async function () {
|
||||
|
@ -78,43 +82,77 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
|
||||
expect(await testSubjects.exists(refreshButtonSelector)).to.be(true);
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
});
|
||||
|
||||
it('should fetch data from ES after refreshDataButton click', async function () {
|
||||
expect(await testSubjects.exists(refreshButtonSelector)).to.be(true);
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
|
||||
await testSubjects.click(refreshButtonSelector);
|
||||
await testSubjects.missingOrFail(refreshButtonSelector);
|
||||
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
});
|
||||
|
||||
it('should fetch data from ES after submit query', async function () {
|
||||
expect(await testSubjects.exists(refreshButtonSelector)).to.be(true);
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
|
||||
await queryBar.submitQuery();
|
||||
await testSubjects.missingOrFail(refreshButtonSelector);
|
||||
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
});
|
||||
|
||||
it('should fetch data from ES after choosing commonly used time range', async function () {
|
||||
await PageObjects.discover.selectIndexPattern('logstash-*');
|
||||
expect(await testSubjects.exists(refreshButtonSelector)).to.be(true);
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
|
||||
await PageObjects.timePicker.setCommonlyUsedTime('This_week');
|
||||
await testSubjects.missingOrFail(refreshButtonSelector);
|
||||
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
});
|
||||
|
||||
it('should fetch data when a search is saved', async function () {
|
||||
await PageObjects.discover.selectIndexPattern('logstash-*');
|
||||
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(0));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
|
||||
await PageObjects.discover.saveSearch(savedSearchName);
|
||||
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
});
|
||||
|
||||
it('should reset state after opening a saved search and pressing New', async function () {
|
||||
await PageObjects.discover.loadSavedSearch(savedSearchName);
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
|
||||
await testSubjects.click('discoverNewButton');
|
||||
await PageObjects.header.waitUntilLoadingHasFinished();
|
||||
|
||||
await retry.waitFor('number of fetches to be 0', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(false);
|
||||
});
|
||||
});
|
||||
|
||||
it(`when it's true should fetch data from ES initially`, async function () {
|
||||
await initSearchOnPageLoad(true);
|
||||
await retry.waitFor('number of fetches to be 1', waitForFetches(1));
|
||||
expect(await PageObjects.discover.doesSidebarShowFields()).to.be(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
import expect from '@kbn/expect';
|
||||
import { FtrService } from '../ftr_provider_context';
|
||||
|
||||
type SidebarSectionName = 'meta' | 'empty' | 'available' | 'unmapped' | 'popular' | 'selected';
|
||||
|
||||
export class DiscoverPageObject extends FtrService {
|
||||
private readonly retry = this.ctx.getService('retry');
|
||||
private readonly testSubjects = this.ctx.getService('testSubjects');
|
||||
|
@ -437,8 +439,61 @@ export class DiscoverPageObject extends FtrService {
|
|||
return await this.testSubjects.exists('discoverNoResultsTimefilter');
|
||||
}
|
||||
|
||||
public async getSidebarAriaDescription(): Promise<string> {
|
||||
return await (
|
||||
await this.testSubjects.find('fieldListGrouped__ariaDescription')
|
||||
).getAttribute('innerText');
|
||||
}
|
||||
|
||||
public async waitUntilSidebarHasLoaded() {
|
||||
await this.retry.waitFor('sidebar is loaded', async () => {
|
||||
return (await this.getSidebarAriaDescription()).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
public async doesSidebarShowFields() {
|
||||
return await this.testSubjects.exists('fieldListGroupedFieldGroups');
|
||||
}
|
||||
|
||||
public getSidebarSectionSelector(
|
||||
sectionName: SidebarSectionName,
|
||||
asCSSSelector: boolean = false
|
||||
) {
|
||||
const testSubj = `fieldListGrouped${sectionName[0].toUpperCase()}${sectionName.substring(
|
||||
1
|
||||
)}Fields`;
|
||||
if (!asCSSSelector) {
|
||||
return testSubj;
|
||||
}
|
||||
return `[data-test-subj="${testSubj}"]`;
|
||||
}
|
||||
|
||||
public async getSidebarSectionFieldNames(sectionName: SidebarSectionName): Promise<string[]> {
|
||||
const elements = await this.find.allByCssSelector(
|
||||
`${this.getSidebarSectionSelector(sectionName, true)} li`
|
||||
);
|
||||
|
||||
if (!elements?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
elements.map(async (element) => await element.getAttribute('data-attr-field'))
|
||||
);
|
||||
}
|
||||
|
||||
public async toggleSidebarSection(sectionName: SidebarSectionName) {
|
||||
return await this.find.clickByCssSelector(
|
||||
`${this.getSidebarSectionSelector(sectionName, true)} .euiAccordion__iconButton`
|
||||
);
|
||||
}
|
||||
|
||||
public async clickFieldListItem(field: string) {
|
||||
return await this.testSubjects.click(`field-${field}`);
|
||||
await this.testSubjects.click(`field-${field}`);
|
||||
|
||||
await this.retry.waitFor('popover is open', async () => {
|
||||
return Boolean(await this.find.byCssSelector('[data-popover-open="true"]'));
|
||||
});
|
||||
}
|
||||
|
||||
public async clickFieldSort(field: string, text = 'Sort New-Old') {
|
||||
|
@ -455,11 +510,16 @@ export class DiscoverPageObject extends FtrService {
|
|||
}
|
||||
|
||||
public async clickFieldListItemAdd(field: string) {
|
||||
await this.waitUntilSidebarHasLoaded();
|
||||
|
||||
// a filter check may make sense here, but it should be properly handled to make
|
||||
// it work with the _score and _source fields as well
|
||||
if (await this.isFieldSelected(field)) {
|
||||
return;
|
||||
}
|
||||
if (['_score', '_id', '_index'].includes(field)) {
|
||||
await this.toggleSidebarSection('meta'); // expand Meta section
|
||||
}
|
||||
await this.clickFieldListItemToggle(field);
|
||||
const isLegacyDefault = await this.useLegacyTable();
|
||||
if (isLegacyDefault) {
|
||||
|
@ -474,16 +534,18 @@ export class DiscoverPageObject extends FtrService {
|
|||
}
|
||||
|
||||
public async isFieldSelected(field: string) {
|
||||
if (!(await this.testSubjects.exists('fieldList-selected'))) {
|
||||
if (!(await this.testSubjects.exists('fieldListGroupedSelectedFields'))) {
|
||||
return false;
|
||||
}
|
||||
const selectedList = await this.testSubjects.find('fieldList-selected');
|
||||
const selectedList = await this.testSubjects.find('fieldListGroupedSelectedFields');
|
||||
return await this.testSubjects.descendantExists(`field-${field}`, selectedList);
|
||||
}
|
||||
|
||||
public async clickFieldListItemRemove(field: string) {
|
||||
await this.waitUntilSidebarHasLoaded();
|
||||
|
||||
if (
|
||||
!(await this.testSubjects.exists('fieldList-selected')) ||
|
||||
!(await this.testSubjects.exists('fieldListGroupedSelectedFields')) ||
|
||||
!(await this.isFieldSelected(field))
|
||||
) {
|
||||
return;
|
||||
|
@ -493,6 +555,8 @@ export class DiscoverPageObject extends FtrService {
|
|||
}
|
||||
|
||||
public async clickFieldListItemVisualize(fieldName: string) {
|
||||
await this.waitUntilSidebarHasLoaded();
|
||||
|
||||
const field = await this.testSubjects.find(`field-${fieldName}-showDetails`);
|
||||
const isActive = await field.elementHasClass('kbnFieldButton-isActive');
|
||||
|
||||
|
|
|
@ -279,8 +279,6 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
|
|||
},
|
||||
});
|
||||
const fieldsExistenceReader = useExistingFieldsReader();
|
||||
const fieldsExistenceStatus =
|
||||
fieldsExistenceReader.getFieldsExistenceStatus(currentIndexPatternId);
|
||||
|
||||
const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER);
|
||||
const allFields = useMemo(() => {
|
||||
|
@ -326,7 +324,6 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
|
|||
[localState]
|
||||
);
|
||||
|
||||
const hasFilters = Boolean(filters.length);
|
||||
const onOverrideFieldGroupDetails = useCallback(
|
||||
(groupName) => {
|
||||
if (groupName === FieldsGroupNames.AvailableFields) {
|
||||
|
@ -342,25 +339,20 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
|
|||
defaultMessage:
|
||||
'Drag and drop available fields to the workspace and create visualizations. To change the available fields, select a different data view, edit your queries, or use a different time range. Some field types cannot be visualized in Lens, including full text and geographic fields.',
|
||||
}),
|
||||
isAffectedByGlobalFilter: hasFilters,
|
||||
};
|
||||
}
|
||||
if (groupName === FieldsGroupNames.SelectedFields) {
|
||||
return {
|
||||
isAffectedByGlobalFilter: hasFilters,
|
||||
};
|
||||
}
|
||||
},
|
||||
[core.uiSettings, hasFilters]
|
||||
[core.uiSettings]
|
||||
);
|
||||
|
||||
const { fieldGroups } = useGroupedFields<IndexPatternField>({
|
||||
const fieldListGroupedProps = useGroupedFields<IndexPatternField>({
|
||||
dataViewId: currentIndexPatternId,
|
||||
allFields,
|
||||
services: {
|
||||
dataViews,
|
||||
},
|
||||
fieldsExistenceReader,
|
||||
isAffectedByGlobalFilter: Boolean(filters.length),
|
||||
onFilterField,
|
||||
onSupportedFieldFilter,
|
||||
onSelectedFieldFilter,
|
||||
|
@ -616,9 +608,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<FieldListGrouped<IndexPatternField>
|
||||
fieldGroups={fieldGroups}
|
||||
fieldsExistenceStatus={fieldsExistenceStatus}
|
||||
fieldsExistInIndex={!!allFields.length}
|
||||
{...fieldListGroupedProps}
|
||||
renderFieldItem={renderFieldItem}
|
||||
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
|
||||
data-test-subj="lnsIndexPattern"
|
||||
|
|
|
@ -239,9 +239,9 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
|
|||
field: value.field.name,
|
||||
},
|
||||
})
|
||||
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceDisabled', {
|
||||
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceNotAvailable', {
|
||||
defaultMessage:
|
||||
"This field can't be added to the workspace automatically. You can still use it directly in the configuration panel.",
|
||||
'To visualize this field, please add it directly to the desired layer. Adding this field to the workspace is not supported based on your current configuration.',
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -382,7 +382,7 @@ function FieldItemPopoverContents(
|
|||
data-test-subj={`lnsFieldListPanel-exploreInDiscover-${dataViewField.name}`}
|
||||
>
|
||||
{i18n.translate('xpack.lens.indexPattern.fieldExploreInDiscover', {
|
||||
defaultMessage: 'Explore values in Discover',
|
||||
defaultMessage: 'Explore in Discover',
|
||||
})}
|
||||
</EuiButton>
|
||||
</EuiPopoverFooter>
|
||||
|
|
|
@ -17,7 +17,6 @@ import { isOfAggregateQueryType } from '@kbn/es-query';
|
|||
import { DatatableColumn, ExpressionsStart } from '@kbn/expressions-plugin/public';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import {
|
||||
ExistenceFetchStatus,
|
||||
FieldListGrouped,
|
||||
FieldListGroupedProps,
|
||||
FieldsGroupNames,
|
||||
|
@ -108,9 +107,9 @@ export function TextBasedDataPanel({
|
|||
}
|
||||
}, []);
|
||||
|
||||
const { fieldGroups } = useGroupedFields<DatatableColumn>({
|
||||
const fieldListGroupedProps = useGroupedFields<DatatableColumn>({
|
||||
dataViewId: null,
|
||||
allFields: fieldList,
|
||||
allFields: dataHasLoaded ? fieldList : null,
|
||||
services: {
|
||||
dataViews,
|
||||
},
|
||||
|
@ -195,11 +194,7 @@ export function TextBasedDataPanel({
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<FieldListGrouped<DatatableColumn>
|
||||
fieldGroups={fieldGroups}
|
||||
fieldsExistenceStatus={
|
||||
dataHasLoaded ? ExistenceFetchStatus.succeeded : ExistenceFetchStatus.unknown
|
||||
}
|
||||
fieldsExistInIndex={Boolean(fieldList.length)}
|
||||
{...fieldListGroupedProps}
|
||||
renderFieldItem={renderFieldItem}
|
||||
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
|
||||
data-test-subj="lnsTextBasedLanguages"
|
||||
|
|
|
@ -2133,13 +2133,9 @@
|
|||
"discover.fieldChooser.fieldFilterButtonLabel": "Filtrer par type",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "Champs",
|
||||
"discover.fieldChooser.filter.aggregatableLabel": "Regroupable",
|
||||
"discover.fieldChooser.filter.availableFieldsTitle": "Champs disponibles",
|
||||
"discover.fieldChooser.filter.filterByTypeLabel": "Filtrer par type",
|
||||
"discover.fieldChooser.filter.hideEmptyFieldsLabel": "Masquer les champs vides",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "Index et champs",
|
||||
"discover.fieldChooser.filter.popularTitle": "Populaire",
|
||||
"discover.fieldChooser.filter.searchableLabel": "Interrogeable",
|
||||
"discover.fieldChooser.filter.selectedFieldsTitle": "Champs sélectionnés",
|
||||
"discover.fieldChooser.filter.toggleButton.any": "tout",
|
||||
"discover.fieldChooser.filter.toggleButton.no": "non",
|
||||
"discover.fieldChooser.filter.toggleButton.yes": "oui",
|
||||
|
@ -17663,7 +17659,6 @@
|
|||
"xpack.lens.indexPattern.min": "Minimum",
|
||||
"xpack.lens.indexPattern.min.description": "Agrégation d'indicateurs à valeur unique qui renvoie la valeur minimale des valeurs numériques extraites des documents agrégés.",
|
||||
"xpack.lens.indexPattern.missingFieldLabel": "Champ manquant",
|
||||
"xpack.lens.indexPattern.moveToWorkspaceDisabled": "Ce champ ne peut pas être ajouté automatiquement à l'espace de travail. Vous pouvez toujours l'utiliser directement dans le panneau de configuration.",
|
||||
"xpack.lens.indexPattern.moving_average.signature": "indicateur : nombre, [window] : nombre",
|
||||
"xpack.lens.indexPattern.movingAverage": "Moyenne mobile",
|
||||
"xpack.lens.indexPattern.movingAverage.basicExplanation": "La moyenne mobile fait glisser une fenêtre sur les données et affiche la valeur moyenne. La moyenne mobile est prise en charge uniquement par les histogrammes des dates.",
|
||||
|
|
|
@ -2129,13 +2129,9 @@
|
|||
"discover.fieldChooser.fieldFilterButtonLabel": "タイプでフィルタリング",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "フィールド",
|
||||
"discover.fieldChooser.filter.aggregatableLabel": "集約可能",
|
||||
"discover.fieldChooser.filter.availableFieldsTitle": "利用可能なフィールド",
|
||||
"discover.fieldChooser.filter.filterByTypeLabel": "タイプでフィルタリング",
|
||||
"discover.fieldChooser.filter.hideEmptyFieldsLabel": "空のフィールドを非表示",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "インデックスとフィールド",
|
||||
"discover.fieldChooser.filter.popularTitle": "人気",
|
||||
"discover.fieldChooser.filter.searchableLabel": "検索可能",
|
||||
"discover.fieldChooser.filter.selectedFieldsTitle": "スクリプトフィールド",
|
||||
"discover.fieldChooser.filter.toggleButton.any": "すべて",
|
||||
"discover.fieldChooser.filter.toggleButton.no": "いいえ",
|
||||
"discover.fieldChooser.filter.toggleButton.yes": "はい",
|
||||
|
@ -17646,7 +17642,6 @@
|
|||
"xpack.lens.indexPattern.min": "最低",
|
||||
"xpack.lens.indexPattern.min.description": "集約されたドキュメントから抽出された数値の最小値を返す単一値メトリック集約。",
|
||||
"xpack.lens.indexPattern.missingFieldLabel": "見つからないフィールド",
|
||||
"xpack.lens.indexPattern.moveToWorkspaceDisabled": "このフィールドは自動的にワークスペースに追加できません。構成パネルで直接使用することはできます。",
|
||||
"xpack.lens.indexPattern.moving_average.signature": "メトリック:数値、[window]:数値",
|
||||
"xpack.lens.indexPattern.movingAverage": "移動平均",
|
||||
"xpack.lens.indexPattern.movingAverage.basicExplanation": "移動平均はデータ全体でウィンドウをスライドし、平均値を表示します。移動平均は日付ヒストグラムでのみサポートされています。",
|
||||
|
|
|
@ -2133,13 +2133,9 @@
|
|||
"discover.fieldChooser.fieldFilterButtonLabel": "按类型筛选",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "字段",
|
||||
"discover.fieldChooser.filter.aggregatableLabel": "可聚合",
|
||||
"discover.fieldChooser.filter.availableFieldsTitle": "可用字段",
|
||||
"discover.fieldChooser.filter.filterByTypeLabel": "按类型筛选",
|
||||
"discover.fieldChooser.filter.hideEmptyFieldsLabel": "隐藏空字段",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "索引和字段",
|
||||
"discover.fieldChooser.filter.popularTitle": "常见",
|
||||
"discover.fieldChooser.filter.searchableLabel": "可搜索",
|
||||
"discover.fieldChooser.filter.selectedFieldsTitle": "选定字段",
|
||||
"discover.fieldChooser.filter.toggleButton.any": "任意",
|
||||
"discover.fieldChooser.filter.toggleButton.no": "否",
|
||||
"discover.fieldChooser.filter.toggleButton.yes": "是",
|
||||
|
@ -17671,7 +17667,6 @@
|
|||
"xpack.lens.indexPattern.min": "最小值",
|
||||
"xpack.lens.indexPattern.min.description": "单值指标聚合,返回从聚合文档提取的数值中的最小值。",
|
||||
"xpack.lens.indexPattern.missingFieldLabel": "缺失字段",
|
||||
"xpack.lens.indexPattern.moveToWorkspaceDisabled": "此字段无法自动添加到工作区。您仍可以在配置面板中直接使用它。",
|
||||
"xpack.lens.indexPattern.moving_average.signature": "指标:数字,[window]:数字",
|
||||
"xpack.lens.indexPattern.movingAverage": "移动平均值",
|
||||
"xpack.lens.indexPattern.movingAverage.basicExplanation": "移动平均值在数据上滑动时间窗并显示平均值。仅日期直方图支持移动平均值。",
|
||||
|
|
|
@ -58,6 +58,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
await kibanaServer.importExport.unload(
|
||||
'x-pack/test/functional/fixtures/kbn_archiver/discover/feature_controls/security'
|
||||
);
|
||||
await kibanaServer.savedObjects.cleanStandardList();
|
||||
});
|
||||
|
||||
describe('global discover all privileges', () => {
|
||||
|
@ -444,12 +445,14 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
await security.user.delete('no_discover_privileges_user');
|
||||
});
|
||||
|
||||
it(`shows 403`, async () => {
|
||||
it('shows 403', async () => {
|
||||
await PageObjects.common.navigateToUrl('discover', '', {
|
||||
ensureCurrentUrl: false,
|
||||
shouldLoginIfPrompted: false,
|
||||
});
|
||||
await PageObjects.error.expectForbidden();
|
||||
await retry.try(async () => {
|
||||
await PageObjects.error.expectForbidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -505,6 +508,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
after(async () => {
|
||||
await kibanaServer.uiSettings.unset('defaultIndex');
|
||||
await esSupertest
|
||||
.post('/_aliases')
|
||||
.send({
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue