diff --git a/src/plugins/discover/public/__mocks__/data_views.ts b/src/plugins/discover/public/__mocks__/data_views.ts index 7832a4c0f4e39..1bc8d791d53fb 100644 --- a/src/plugins/discover/public/__mocks__/data_views.ts +++ b/src/plugins/discover/public/__mocks__/data_views.ts @@ -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; +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; +} + +export const dataViewsMock = createDiscoverDataViewsMock(); diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts index 97de63d231a46..c8a6bd45131ba 100644 --- a/src/plugins/discover/public/__mocks__/services.ts +++ b/src/plugins/discover/public/__mocks__/services.ts @@ -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'; @@ -23,107 +24,122 @@ import { import { UI_SETTINGS } 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'; + +export function createDiscoverServicesMock(): DiscoverServices { + const dataPlugin = dataPluginMock.createStartContract(); + const expressionsPlugin = expressionsPluginMock.createStartContract(); -dataPlugin.query.filterManager.getFilters = jest.fn(() => []); -dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable); + dataPlugin.query.filterManager.getFilters = jest.fn(() => []); + dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable); + 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.dataViews = createDiscoverDataViewsMock(); -export const discoverServiceMock = { - core: coreMock.createStart(), - chrome: chromeServiceMock.createStartContract(), - history: () => ({ - location: { - search: '', + return { + core: coreMock.createStart(), + charts: chartPluginMock.createSetupContract(), + chrome: chromeServiceMock.createStartContract(), + history: () => ({ + location: { + search: '', + }, + listen: jest.fn(), + }), + data: dataPlugin, + docLinks: docLinksServiceMock.createStartContract(), + capabilities: { + visualize: { + show: true, + }, + discover: { + save: false, + }, + advancedSettings: { + save: true, + }, }, - listen: jest.fn(), - }), - data: dataPlugin, - docLinks: docLinksServiceMock.createStartContract(), - capabilities: { - visualize: { - show: true, + fieldFormats: fieldFormatsMock, + filterManager: dataPlugin.query.filterManager, + inspector: { + open: jest.fn(), }, - discover: { - save: false, + 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; + }, }, - advancedSettings: { - save: true, + http: { + basePath: '/', }, - }, - 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; - } - }), - isDefault: (key: string) => { - return true; + dataViewEditor: { + userPermissions: { + editDataView: () => true, + }, + }, + dataViewFieldEditor: { + openEditor: jest.fn(), + userPermissions: { + editIndexPattern: jest.fn(), + }, + }, + navigation: { + ui: { TopNavMenu, AggregateQueryTopNavMenu: TopNavMenu }, }, - }, - http: { - basePath: '/', - }, - dataViewEditor: { - userPermissions: { - editDataView: () => true, + metadata: { + branch: 'test', }, - }, - dataViewFieldEditor: { - openEditor: jest.fn(), - userPermissions: { - editIndexPattern: jest.fn(), + theme: { + useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), + useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme), }, - }, - 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() }, -} as unknown as DiscoverServices; + 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, + } as unknown as DiscoverServices; +} + +export const discoverServiceMock = createDiscoverServicesMock(); diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss index 70963f50b96a7..1d314cc72efb4 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.scss +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.scss @@ -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 diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx index 0e9c7f8449520..0e28fc4f32069 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx @@ -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]; diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx index a7cde67d4869b..5b94309eb5f7b 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx @@ -241,7 +241,7 @@ export function DiscoverLayout({ history={history} /> - + 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 }; diff --git a/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx b/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx deleted file mode 100644 index 9b02ffd15a282..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/__stories__/discover_field_details.stories.tsx +++ /dev/null @@ -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', () => ( -
- { - alert('On add filter clicked'); - }} - /> -
- )) - .add('scripted', () => ( -
- {}} - /> -
- )) - .add('error', () => ( - {}} - /> - )); diff --git a/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts b/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts deleted file mode 100644 index 04f1cb9eb618b..0000000000000 --- a/src/plugins/discover/public/application/main/components/sidebar/__stories__/fields.ts +++ /dev/null @@ -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 = { - '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, -}); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_bucket.scss b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_bucket.scss similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/discover_field_bucket.scss rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_bucket.scss diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_bucket.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_bucket.tsx similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/discover_field_bucket.tsx rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_bucket.tsx diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx similarity index 73% rename from src/plugins/discover/public/application/main/components/sidebar/discover_field_details.test.tsx rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx index 9338bd36ceab2..535459c880988 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_details.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.test.tsx @@ -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(); } diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx similarity index 69% rename from src/plugins/discover/public/application/main/components/sidebar/discover_field_details.tsx rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx index 69e5c01df07e5..58db010c025c9 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/discover_field_details.tsx @@ -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 ( - <> +
+ +
+ {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} +
+
{details.error && {details.error}} {!details.error && ( <> @@ -70,6 +94,6 @@ export function DiscoverFieldDetails({ )} - +
); } diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/field_calculator.js b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/field_calculator.js similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/lib/field_calculator.js rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/field_calculator.js diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/field_calculator.test.ts b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/field_calculator.test.ts similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/lib/field_calculator.test.ts rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/field_calculator.test.ts diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/get_details.ts b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/get_details.ts similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/lib/get_details.ts rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/get_details.ts diff --git a/src/plugins/discover/public/application/main/components/sidebar/string_progress_bar.tsx b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/string_progress_bar.tsx similarity index 100% rename from src/plugins/discover/public/application/main/components/sidebar/string_progress_bar.tsx rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/string_progress_bar.tsx diff --git a/src/plugins/discover/public/application/main/components/sidebar/types.ts b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/types.ts similarity index 86% rename from src/plugins/discover/public/application/main/components/sidebar/types.ts rename to src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/types.ts index 45921f676f144..1f7d40418fe7b 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/types.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/deprecated_stats/types.ts @@ -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; diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx index dd27369a74a04..82e3e462dbd3a 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field.test.tsx @@ -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,25 +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', - }), - }, - }, - }, - }, - dataViews: dataViewPluginMocks.createStartContract(), - fieldFormats: fieldFormatsServiceMock.createStartContract(), - charts: chartPluginMock.createSetupContract(), }; const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer; appStateContainer.set({ @@ -152,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'); @@ -162,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({ @@ -180,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({ @@ -205,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({ diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx index 03e742da3514f..4968ca0003090 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field.tsx @@ -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,13 +30,13 @@ import { FieldPopoverVisualize, } from '@kbn/unified-field-list-plugin/public'; 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 { useAppStateSelector } from '../../services/discover_app_state_container'; +import { type DataDocuments$ } from '../../hooks/use_saved_search'; function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows @@ -65,24 +72,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 ( - - {wrapOnDot(field.displayName)} - - ); -}); + return ( + + {wrapOnDot(field.displayName)} + + ); + } +); interface ActionButtonProps { field: DataViewField; @@ -129,6 +143,7 @@ const ActionButton: React.FC = memo( } else { return ( ; toggleDisplay: (field: DataViewField) => void; alwaysShowActionButton: boolean; - isDocumentRecord: boolean; } const MultiFields: React.FC = memo( - ({ multiFields, toggleDisplay, alwaysShowActionButton, isDocumentRecord }) => ( + ({ multiFields, toggleDisplay, alwaysShowActionButton }) => (
@@ -185,7 +199,7 @@ const MultiFields: React.FC = memo( className="dscSidebarItem dscSidebarItem--multi" isActive={false} dataTestSubj={`field-${entry.field.name}-showDetails`} - fieldIcon={isDocumentRecord && } + fieldIcon={} fieldAction={ = 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 */ @@ -228,10 +246,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 */ @@ -265,16 +279,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, @@ -349,7 +369,7 @@ function DiscoverFieldComponent({ size="s" className="dscSidebarItem" dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={isDocumentRecord && } + fieldIcon={} fieldAction={ } + fieldIcon={} fieldAction={ } - fieldName={} + fieldName={} fieldInfoIcon={field.type === 'conflict' && } /> ); @@ -399,24 +419,15 @@ function DiscoverFieldComponent({ return ( <> - {showLegacyFieldStats ? ( + {showLegacyFieldStats ? ( // TODO: Deprecate and remove after ~v8.7 <> {showFieldStats && ( - <> - -
- {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { - defaultMessage: 'Top 5 values', - })} -
-
- - + )} ) : ( @@ -444,7 +455,6 @@ function DiscoverFieldComponent({ multiFields={multiFields} alwaysShowActionButton={alwaysShowActionButton} toggleDisplay={toggleDisplay} - isDocumentRecord={isDocumentRecord} /> )} diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.test.tsx index 8f54936f4963b..a678880100ccd 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.test.tsx @@ -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'); - 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', () => { diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx index 59ba2833d94f5..b7bc9ff93bf8c 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_field_search.tsx @@ -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; } @@ -112,7 +108,6 @@ export function DiscoverFieldSearch({ searchable: 'any', aggregatable: 'any', type: 'any', - missing: true, }); const { docLinks } = useDiscoverServices(); @@ -191,7 +186,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 +209,6 @@ export function DiscoverFieldSearch({ setActiveFiltersCount(activeFiltersCount + diff); }; - const handleMissingChange = (e: EuiSwitchEvent) => { - const missingValue = e.target.checked; - handleValueChange('missing', missingValue); - }; - const buttonContent = ( { - return ( - - - - ); - }; - const selectionPanel = (
@@ -356,7 +331,7 @@ export function DiscoverFieldSearch({ 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 +359,6 @@ export function DiscoverFieldSearch({ })} {selectionPanel} - {footer()} >>, [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 = {}; for (const hit of hits) { @@ -54,16 +56,34 @@ function getCompProps(): DiscoverSidebarProps { fieldCounts[key] = (fieldCounts[key] || 0) + 1; } } + + (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, + dataViewList: [dataView as DataViewListItem], onChangeDataView: jest.fn(), onAddFilter: jest.fn(), onAddField: jest.fn(), @@ -77,37 +97,37 @@ function getCompProps(): DiscoverSidebarProps { viewMode: VIEW_MODE.DOCUMENT_LEVEL, createNewDataView: jest.fn(), onDataViewCreated: jest.fn(), + documents$, + documents: hits, availableFields$, useNewFieldsApi: true, }; } -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> { let comp: ReactWrapper; + 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( - - + + @@ -117,20 +137,36 @@ describe('discover sidebar', function () { await comp.update(); }); + await comp!.update(); + + return comp!; +} + +describe('discover sidebar', function () { + let props: DiscoverSidebarProps; + let comp: ReactWrapper; + + beforeEach(async () => { + props = getCompProps(); + comp = await mountComponent(props); + }); + 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); + const popularFieldsCount = findTestSubject(comp, 'fieldListGroupedPopularFields-count'); + const selectedFieldsCount = findTestSubject(comp, 'fieldListGroupedSelectedFields-count'); + const availableFieldsCount = findTestSubject(comp, 'fieldListGroupedAvailableFields-count'); + expect(popularFieldsCount.text()).toBe('1'); + expect(availableFieldsCount.text()).toBe('3'); + expect(selectedFieldsCount.text()).toBe('1'); }); it('should allow selecting fields', function () { - findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + 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'); + const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields'); + findTestSubject(availableFields, 'fieldToggle-extension').simulate('click'); expect(props.onRemoveField).toHaveBeenCalledWith('extension'); }); @@ -142,8 +178,10 @@ 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 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 +189,27 @@ describe('discover sidebar', function () { expect(props.editField).toHaveBeenCalledWith('bytes'); }); - it('should not render Add/Edit field buttons in viewer mode', () => { - const compInViewerMode = mountWithIntl( - - - - - - ); + 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(comp, '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( - - - - - - ); + 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 +220,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( - - - - - - ); + 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 +248,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( - - - - - - ); + 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); }); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx index 4735b75b66e14..8fdaf14a88a9b 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx @@ -7,49 +7,46 @@ */ 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, + EuiButton, EuiFlexGroup, - EuiText, - EuiTitle, - EuiSpacer, - EuiNotificationBadge, + EuiFlexItem, EuiPageSideBar_Deprecated as EuiPageSideBar, - useResizeObserver, - EuiButton, } 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 { + ExistenceFetchStatus, + 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 { getSelectedFields, shouldShowField } from './lib/group_fields'; +import { doesFieldMatchFilters, FieldFilterState, setFieldFilterProp } from './lib/field_filter'; import { getDataViewFieldList } from './lib/get_data_view_field_list'; 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 EMPTY_FIELD_LIST: DataViewField[] = []; -export interface DiscoverSidebarProps extends Omit { +export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps { /** * Current state of the field filter, filtering fields by name, type, ... */ @@ -104,7 +101,8 @@ export function DiscoverSidebarComponent({ columns, fieldCounts, fieldFilter, - documents, + documents$, + documents, // TODO: remove onAddField, onAddFilter, onRemoveField, @@ -121,14 +119,13 @@ export function DiscoverSidebarComponent({ createNewDataView, showDataViewPicker, }: DiscoverSidebarProps) { - const { uiSettings, dataViewFieldEditor } = useDiscoverServices(); + const { uiSettings, dataViewFieldEditor, dataViews } = useDiscoverServices(); const [fields, setFields] = useState(null); - const [scrollContainer, setScrollContainer] = useState(null); - const [fieldsToRender, setFieldsToRender] = useState(FIELDS_PER_PAGE); - const [fieldsPerPage, setFieldsPerPage] = useState(FIELDS_PER_PAGE); - const availableFieldsContainer = useRef(null); - const isPlainRecord = !onAddFilter; + const isPlainRecord = useAppStateSelector( + (state) => getRawRecordType(state.query) === RecordRawType.PLAIN + ); const query = useAppStateSelector((state) => state.query); + const isGlobalFilterApplied = useAppStateSelector((state) => Boolean(state.filters?.length)); useEffect(() => { if (documents) { @@ -137,85 +134,17 @@ export function DiscoverSidebarComponent({ } }, [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] - ); - - const getDetailsByField = useCallback( - (ipField: DataViewField) => getDetails(ipField, documents, selectedDataView), - [documents, selectedDataView] + [fieldFilter, setFieldFilter] ); - 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 selectedFields = useMemo(() => { + return getSelectedFields(fields, columns); + }, [fields, columns]); const { fieldTypes, presentFieldTypes } = useMemo(() => { const result = ['any']; @@ -305,15 +234,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 +245,98 @@ export function DiscoverSidebarComponent({ ); }, [columns, selectedDataView, query]); + const popularFieldsLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]); + const onFilterField: GroupedFieldsParams['onFilterField'] = useCallback( + (field) => { + return doesFieldMatchFilters(field, fieldFilter); + }, + [fieldFilter] + ); + const onSupportedFieldFilter: GroupedFieldsParams['onSupportedFieldFilter'] = + useCallback( + (field) => { + return shouldShowField(field, useNewFieldsApi); + }, + [useNewFieldsApi] + ); + const onOverrideFieldGroupDetails: GroupedFieldsParams['onOverrideFieldGroupDetails'] = + useCallback((groupName) => { + if (groupName === FieldsGroupNames.AvailableFields) { + return { + helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', { + defaultMessage: + 'Your query returned values for these fields. Click + to add an available field to the data table.', + }), + }; + } + }, []); + const fieldsExistenceReader = useExistingFieldsReader(); + const { fieldGroups } = useGroupedFields({ + dataViewId: isPlainRecord || !selectedDataView?.id ? null : selectedDataView.id, // TODO: check whether we need Empty fields for text-based query + fieldsExistenceReader, + allFields: fields || EMPTY_FIELD_LIST, + popularFieldsLimit: isPlainRecord ? 0 : popularFieldsLimit, + sortedSelectedFields: selectedFields, + isAffectedByGlobalFilter: isGlobalFilterApplied, + services: { + dataViews, + }, + onFilterField, + onSupportedFieldFilter, + onOverrideFieldGroupDetails, + }); + + // TODO: hide meta fields on Discover for text-based queries + + // console.log({ + // fields, + // oldSelectedFields, + // popularFields, + // unpopularFields, + // fieldGroups, + // columns, + // }); + + const renderFieldItem: FieldListGroupedProps['renderFieldItem'] = useCallback( + ({ field, groupName }) => ( +
  • + +
  • + ), + [ + alwaysShowActionButtons, + selectedDataView, + onAddField, + onRemoveField, + onAddFilter, + documents$, + trackUiMetric, + multiFields, + editField, + deleteField, + showFieldStats, + columns, + selectedFields, + fieldFilter.name, + ] + ); + if (!selectedDataView) { return null; } @@ -370,166 +382,17 @@ export function DiscoverSidebarComponent({ /> - -
    { - 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 && ( -
    - {selectedFields && - selectedFields.length > 0 && - selectedFields[0].displayName !== '_source' ? ( - <> - - - - - - } - extraAction={ - - {selectedFields.length} - - } - > - -
      - {selectedFields.map((field: DataViewField) => { - return ( -
    • - -
    • - ); - })} -
    -
    - {' '} - - ) : null} - - - - - - } - extraAction={ - - {restFields.length} - - } - > - - {!isPlainRecord && popularFields.length > 0 && ( - <> - - - -
      - {popularFields.map((field: DataViewField) => { - return ( -
    • - -
    • - ); - })} -
    - - )} -
      - {getPaginated(restFields).map((field: DataViewField) => { - return ( -
    • - -
    • - ); - })} -
    -
    -
    - )} -
    + + {!!editField && ( diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx index 40cab06039f6e..7f85185c6d4ca 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -24,12 +24,11 @@ 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 { createDiscoverServicesMock } from '../../../../__mocks__/services'; +import type { AggregateQuery, Query } from '@kbn/es-query'; jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({ loadFieldStats: jest.fn().mockResolvedValue({ @@ -67,55 +66,26 @@ 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), +function createMockServices() { + const mockServices = { + ...createDiscoverServicesMock(), + capabilities: { + visualize: { + show: true, + }, + discover: { + save: false, + }, }, - }, - 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', - }), - }, + docLinks: { links: { discover: { fieldTypeHelp: '' } } }, + dataViewEditor: { + userPermissions: { + editDataView: jest.fn(() => true), }, }, - }, - dataViews: dataViewPluginMocks.createStartContract(), - fieldFormats: fieldFormatsServiceMock.createStartContract(), - charts: chartPluginMock.createSetupContract(), -} as unknown as DiscoverServices; + } as unknown as DiscoverServices; + return mockServices; +} const mockfieldCounts: Record = {}; const mockCalcFieldCounts = jest.fn(() => { @@ -134,18 +104,17 @@ jest.mock('../../utils/calc_field_counts', () => ({ calcFieldCounts: () => mockCalcFieldCounts(), })); +jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting').mockImplementation(async () => ({ + indexPatternTitle: 'test', + existingFieldNames: Object.keys(mockfieldCounts), +})); + function getCompProps(): 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, - ]; - for (const hit of hits) { for (const key of Object.keys(hit.flattened)) { mockfieldCounts[key] = (mockfieldCounts[key] || 0) + 1; @@ -162,7 +131,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(), @@ -176,52 +145,82 @@ 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> { + let comp: ReactWrapper; + 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( + + + + + + ); + // 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; beforeAll(async () => { props = getCompProps(); - await act(async () => { - const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer; - appStateContainer.set({ - query: { query: '', language: 'lucene' }, - filters: [], - }); - - comp = await mountWithIntl( - - - - - - ); - // wait for lazy modules - await new Promise((resolve) => setTimeout(resolve, 0)); - await comp.update(); - }); + comp = await mountComponent(props); }); - 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, and Popular Fields sections', async function () { + 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'); + + expect(selectedFieldsCount.text()).toBe('1'); + expect(popularFieldsCount.text()).toBe('1'); + expect(availableFieldsCount.text()).toBe('3'); + expect(emptyFieldsCount.text()).toBe('20'); + expect(metaFieldsCount.text()).toBe('2'); expect(mockCalcFieldCounts.mock.calls.length).toBe(1); }); it('should allow selecting fields', function () { - findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + 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'); + const selectedFields = findTestSubject(comp, 'fieldListGroupedSelectedFields'); + findTestSubject(selectedFields, 'fieldToggle-extension').simulate('click'); expect(props.onRemoveField).toHaveBeenCalledWith('extension'); }); it('should allow adding filters', async function () { + 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(); }); @@ -231,8 +230,9 @@ describe('discover responsive sidebar', function () { expect(props.onAddFilter).toHaveBeenCalled(); }); it('should allow adding "exist" filter', async function () { + 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(); }); @@ -241,24 +241,25 @@ 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 () { + expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('3'); + 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(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(); + 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', () => { + it('should not show "Add a field" button on the sql mode', async () => { const initialProps = getCompProps(); const propsWithTextBasedMode = { ...initialProps, @@ -269,46 +270,17 @@ 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( - - - - - - ); expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0); }); - 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: [], - }); - const compInViewerMode = mountWithIntl( - - - - - - ); - 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); }); }); diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx index 9e8ad96f9abb6..a62f5908b653e 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx @@ -19,10 +19,12 @@ import { EuiIcon, EuiLink, EuiPortal, + EuiProgress, EuiShowFor, EuiTitle, } from '@elastic/eui'; import type { DataView, DataViewField, DataViewListItem } from '@kbn/data-views-plugin/public'; +import { useExistingFieldsFetcher } 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'; @@ -30,7 +32,7 @@ import { AvailableFields$, DataDocuments$, RecordRawType } from '../../hooks/use 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 { 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'; @@ -111,6 +113,7 @@ 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 ); @@ -144,6 +147,28 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) fieldCounts.current = {}; }, [selectedDataView]); + const query = useAppStateSelector((state) => state.query); + const filters = useAppStateSelector((state) => state.filters); + const dateRange = data.query.timefilter.timefilter.getTime(); // TODO: is it correct to use the relative time range instead of absolute time range here? Currently, it helps to avoid unnecessary refetches. + + const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({ + dataViews: selectedDataView ? [selectedDataView] : [], + query: query!, + filters: filters!, + fromDate: dateRange.from, + toDate: dateRange.to, + services: { + data, + dataViews, + core, + }, + }); + + const onFieldEditedExtended = useCallback(async () => { + await onFieldEdited(); + refetchFieldsExistenceInfo(); + }, [onFieldEdited, refetchFieldsExistenceInfo]); + const closeFieldEditor = useRef<() => void | undefined>(); const closeDataViewEditor = useRef<() => void | undefined>(); @@ -215,7 +240,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) }, fieldName, onSave: async () => { - await onFieldEdited(); + await onFieldEditedExtended(); }, }); if (setFieldEditorRef) { @@ -233,7 +258,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) selectedDataView, setFieldEditorRef, closeFlyout, - onFieldEdited, + onFieldEditedExtended, ] ); @@ -259,8 +284,10 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) <> {!props.isClosed && ( + {isProcessing && } setIsFlyoutVisible(true)} @@ -324,6 +351,7 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
    { 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); diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/field_filter.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/field_filter.ts index 0ca0cea75ad5c..1f2ab0b9b64cd 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/field_filter.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/field_filter.ts @@ -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 + 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; } diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts index 10d9d4face166..a7ab449d8417b 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { groupFields } from './group_fields'; -import { getDefaultFieldFilter } from './field_filter'; +import { getSelectedFields } from './group_fields'; import { DataViewField } from '@kbn/data-views-plugin/public'; const fields = [ @@ -43,73 +42,27 @@ const fields = [ }, ]; -const fieldCounts = { - category: 1, - currency: 1, - customer_birth_date: 1, - unknown_field: 1, -}; - 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 into selected group', function () { + const actual = getSelectedFields(fields as DataViewField[], ['currency']); expect(actual).toMatchInlineSnapshot(` - Object { - "popular": 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", - ], - "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", - }, - ], - } + Array [ + Object { + "aggregatable": true, + "count": 0, + "esTypes": Array [ + "keyword", + ], + "name": "currency", + "readFromDocValues": true, + "scripted": false, + "searchable": true, + "type": "string", + }, + ] `); }); - it('should group fields in selected, popular, unpopular group if they contain multifields', function () { + it('should pick fields into selected group if they contain multifields', function () { const category = { name: 'category', type: 'string', @@ -169,108 +122,75 @@ describe('group_fields', function () { }; const fieldsToGroup = [category, currency, currencyKeyword] as DataViewField[]; - const fieldFilterState = getDefaultFieldFilter(); + const actual = getSelectedFields(fieldsToGroup, ['currency', 'currency.keyword']); - const actual = groupFields(fieldsToGroup, ['currency'], 5, fieldCounts, fieldFilterState, true); - - expect(actual.popular).toEqual([category]); - expect(actual.selected).toEqual([currency]); - expect(actual.unpopular).toEqual([]); + expect(actual).toEqual([currency, currencyKeyword]); }); it('should sort selected fields by columns order ', function () { - const fieldFilterState = getDefaultFieldFilter(); - - const actual1 = groupFields( - fields as DataViewField[], - ['customer_birth_date', 'currency', 'unknown'], - 5, - fieldCounts, - fieldFilterState, - false - ); - expect(actual1.selected.map((field) => field.name)).toEqual([ + const actual1 = getSelectedFields(fields as DataViewField[], [ + 'customer_birth_date', + 'currency', + 'unknown', + ]); + expect(actual1.map((field) => field.name)).toEqual([ 'customer_birth_date', 'currency', 'unknown', ]); - const actual2 = groupFields( - fields as DataViewField[], - ['currency', 'customer_birth_date', 'unknown'], - 5, - fieldCounts, - fieldFilterState, - false - ); - expect(actual2.selected.map((field) => field.name)).toEqual([ + const actual2 = getSelectedFields(fields as DataViewField[], [ + 'currency', + 'customer_birth_date', + 'unknown', + ]); + expect(actual2.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, - }); - - const actual = groupFields( - fieldsWithUnmappedField as DataViewField[], - ['customer_birth_date', 'currency'], - 5, - fieldCounts, - fieldFilterState, - 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, - false - ); - expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']); - }); + // it('excludes unmapped fields if useNewFieldsApi set to true', function () { + // const fieldsWithUnmappedField = [...fields]; + // fieldsWithUnmappedField.push({ + // name: 'unknown_field', + // type: 'unknown', + // esTypes: ['unknown'], + // count: 1, + // scripted: false, + // searchable: true, + // aggregatable: true, + // readFromDocValues: true, + // }); + // + // expect( + // (fieldsWithUnmappedField as DataViewField[]).filter((field) => shouldShowField(field, true)) + // .length + // ).toBe(fieldsWithUnmappedField.length - 1); + // }); + + // 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, + // fieldFilterState, + // false + // ); + // expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']); + // }); }); diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx index 0d7d535538c44..0cf791648bcb6 100644 --- a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx @@ -6,90 +6,48 @@ * Side Public License, v 1. */ +import { uniqBy } from 'lodash'; import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public'; -import { FieldFilterState, isFieldFiltered } from './field_filter'; -interface GroupedFields { - selected: DataViewField[]; - popular: DataViewField[]; - unpopular: DataViewField[]; +export function shouldShowField(field: DataViewField, useNewFieldsApi: boolean): boolean { + if (field.type === '_source') { + return false; + } + const subTypeMulti = getFieldSubtypeMulti(field?.spec); + const isSubfield = useNewFieldsApi && subTypeMulti; + return !useNewFieldsApi || !isSubfield; } -/** - * group the fields into selected, popular and unpopular, filter by fieldFilterState - */ -export function groupFields( +export function getSelectedFields( fields: DataViewField[] | null, - columns: string[], - popularLimit: number, - fieldCounts: Record | undefined, - fieldFilterState: FieldFilterState, - useNewFieldsApi: boolean -): GroupedFields { - const showUnmappedFields = useNewFieldsApi; - const result: GroupedFields = { - selected: [], - popular: [], - unpopular: [], - }; - if (!Array.isArray(fields) || !Array.isArray(columns) || typeof fieldCounts !== 'object') { - return result; + columns: string[] +): DataViewField[] { + let selectedFields: DataViewField[] = []; + if (!Array.isArray(fields) || !Array.isArray(columns)) { + return []; } - 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 = + fields.find((field) => field.name === column) || + ({ + name: column, + displayName: column, + type: 'unknown_selected', + } as DataViewField); + selectedFields.push(selectedField); } - result.selected.sort((a, b) => { + + selectedFields = uniqBy(selectedFields, 'name'); + + if (selectedFields.length === 1 && selectedFields[0].name === '_source') { + return []; + } + + selectedFields.sort((a, b) => { return columns.indexOf(a.name) - columns.indexOf(b.name); }); - return result; + return selectedFields; } diff --git a/src/plugins/discover/public/application/main/discover_main_app.test.tsx b/src/plugins/discover/public/application/main/discover_main_app.test.tsx index 599eaa57aea3d..c487d78836564 100644 --- a/src/plugins/discover/public/application/main/discover_main_app.test.tsx +++ b/src/plugins/discover/public/application/main/discover_main_app.test.tsx @@ -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( - - - - - - ); + await act(async () => { + const component = await mountWithIntl( + + + + + + ); - 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); + }); }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts index e1020404d3996..af2090347124d 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_chart.test.ts @@ -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", }, diff --git a/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx b/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx index 030876291aeea..f436d7784ed4b 100644 --- a/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx +++ b/src/plugins/discover/public/components/discover_tour/discover_tour_anchors.tsx @@ -9,12 +9,11 @@ import { htmlIdGenerator } from '@elastic/eui'; export const DISCOVER_TOUR_STEP_ANCHOR_IDS = { - addFields: htmlIdGenerator('dsc-tour-step-add-fields')(), expandDocument: htmlIdGenerator('dsc-tour-step-expand')(), }; export const DISCOVER_TOUR_STEP_ANCHORS = { - addFields: `#${DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}`, + addFields: '[data-test-subj="fieldListGroupedAvailableFields"]', reorderColumns: '[data-test-subj="dataGridColumnSelectorButton"]', sort: '[data-test-subj="dataGridColumnSortingButton"]', changeRowHeight: '[data-test-subj="dataGridDisplaySelectorButton"]', diff --git a/src/plugins/discover/public/utils/get_type_for_field_icon.test.ts b/src/plugins/discover/public/utils/get_type_for_field_icon.test.ts index bce15f5d5eb22..33decc463d013 100644 --- a/src/plugins/discover/public/utils/get_type_for_field_icon.test.ts +++ b/src/plugins/discover/public/utils/get_type_for_field_icon.test.ts @@ -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'); + }); }); diff --git a/src/plugins/discover/public/utils/get_type_for_field_icon.ts b/src/plugins/discover/public/utils/get_type_for_field_icon.ts index 429fdf87991eb..3d05e8365e59c 100644 --- a/src/plugins/discover/public/utils/get_type_for_field_icon.ts +++ b/src/plugins/discover/public/utils/get_type_for_field_icon.ts @@ -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; +}; diff --git a/src/plugins/unified_field_list/README.md b/src/plugins/unified_field_list/README.md index 9030a32a3bdca..23edffd5101dc 100755 --- a/src/plugins/unified_field_list/README.md +++ b/src/plugins/unified_field_list/README.md @@ -6,6 +6,8 @@ This Kibana plugin contains components and services for field list UI (as in fie ## Components +* `` - renders a fields list which is split in sections (Selected, Special, Available, Empty, Meta fields). It accepts already grouped fields, please use `useGroupedFields` hook for it. + * `` - loads and renders stats (Top values, Distribution) for a data view field. * `` - renders a button to open this field in Lens. @@ -13,7 +15,7 @@ This Kibana plugin contains components and services for field list UI (as in fie * `` - a popover container component for a field. * `` - this header component included a field name and common actions. -* + * `` - renders Visualize action in the popover footer. These components can be combined and customized as the following: @@ -59,6 +61,47 @@ These components can be combined and customized as the following: * `loadFieldExisting(...)` - returns the loaded existing fields (can also work with Ad-hoc data views) +## Hooks + +* `useExistingFieldsFetcher(...)` - this hook is responsible for fetching fields existence info for specified data views. It can be used higher in components tree than `useExistingFieldsReader` hook. + +* `useExistingFieldsReader(...)` - you can call this hook to read fields existence info which was fetched by `useExistingFieldsFetcher` hook. Using multiple "reader" hooks from different children components is supported. So you would need only one "fetcher" and as many "reader" hooks as necessary. + +* `useGroupedFields(...)` - this hook groups fields list into sections of Selected, Special, Available, Empty, Meta fields. + +An example of using hooks together with ``: + +``` +const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({ + dataViews, + query, + filters, + fromDate, + toDate, + ... +}); +const fieldsExistenceReader = useExistingFieldsReader() +const { fieldGroups } = useGroupedFields({ + dataViewId: currentDataViewId, + allFields, + fieldsExistenceReader, + ... +}); + +// and now we can render a field list + + +// or check whether a field contains data +const { hasFieldData } = useExistingFieldsReader(); +const hasData = hasFieldData(currentDataViewId, fieldName) // return a boolean +``` + ## Server APIs * `/api/unified_field_list/field_stats` - returns the loaded field stats (except for Ad-hoc data views) diff --git a/src/plugins/unified_field_list/common/utils/field_existing_utils.ts b/src/plugins/unified_field_list/common/utils/field_existing_utils.ts index fcc08a141dae1..006568bf37f2e 100644 --- a/src/plugins/unified_field_list/common/utils/field_existing_utils.ts +++ b/src/plugins/unified_field_list/common/utils/field_existing_utils.ts @@ -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, }; }); diff --git a/x-pack/plugins/lens/public/datasources/form_based/field_list.scss b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss similarity index 78% rename from x-pack/plugins/lens/public/datasources/form_based/field_list.scss rename to src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss index f28581b835b07..cd4b9ba2f6e22 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/field_list.scss +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.scss @@ -2,7 +2,7 @@ * 1. Don't cut off the shadow of the field items */ -.lnsIndexPatternFieldList { +.unifiedFieldList__fieldListGrouped { @include euiOverflowShadow; @include euiScrollBar; margin-left: -$euiSize; /* 1 */ @@ -11,7 +11,7 @@ overflow: auto; } -.lnsIndexPatternFieldList__accordionContainer { +.unifiedFieldList__fieldListGrouped__container { padding-top: $euiSizeS; position: absolute; top: 0; diff --git a/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx new file mode 100644 index 0000000000000..59cd7e56ff390 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.test.tsx @@ -0,0 +1,413 @@ +/* + * 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 React from 'react'; +import { act } from 'react-dom/test-utils'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { EuiText, EuiLoadingSpinner } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { ReactWrapper } from 'enzyme'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import FieldListGrouped, { type FieldListGroupedProps } from './field_list_grouped'; +import { ExistenceFetchStatus } from '../../types'; +import { FieldsAccordion } from './fields_accordion'; +import { NoFieldsCallout } from './no_fields_callout'; +import { useGroupedFields, type GroupedFieldsParams } from '../../hooks/use_grouped_fields'; + +describe('UnifiedFieldList + useGroupedFields()', () => { + let defaultProps: FieldListGroupedProps; + let mockedServices: GroupedFieldsParams['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. + const manyFields = [...new Array(5)].flatMap((_, index) => + allFields.map((field) => { + return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` }); + }) + ); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + mockedServices = { + dataViews, + }; + + dataViews.get.mockImplementation(async (id: string) => { + return dataView; + }); + + defaultProps = { + fieldGroups: {}, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + fieldsExistInIndex: true, + screenReaderDescriptionForSearchInputId: 'testId', + renderFieldItem: jest.fn(({ field, itemIndex, groupIndex }) => ( + + {field.name} + + )), + }; + }); + + interface WrapperProps { + listProps: Omit, 'fieldGroups'>; + hookParams: Omit, 'services'>; + } + + async function mountGroupedList({ listProps, hookParams }: WrapperProps): Promise { + const Wrapper: React.FC = (props) => { + const { fieldGroups } = useGroupedFields({ + ...props.hookParams, + services: mockedServices, + }); + + return ; + }; + + let wrapper: ReactWrapper; + await act(async () => { + wrapper = await mountWithIntl(); + // wait for lazy modules if any + await new Promise((resolve) => setTimeout(resolve, 0)); + await wrapper.update(); + }); + + return wrapper!; + } + + it('renders correctly in empty state', () => { + const wrapper = mountWithIntl( + + ); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe(''); + }); + + it('renders correctly in loading state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.unknown, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.unknown + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe(''); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(3); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([false, false, false]); + expect(wrapper.find(NoFieldsCallout)).toHaveLength(0); + + await act(async () => { + await wrapper.setProps({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + }); + await wrapper.update(); + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.succeeded + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([true, true, true]); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 0]); + expect(wrapper.find(NoFieldsCallout)).toHaveLength(1); + }); + + it('renders correctly in failed state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.failed, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect(wrapper.find(FieldListGrouped).prop('fieldsExistenceStatus')).toBe( + ExistenceFetchStatus.failed + ); + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('hasLoaded')) + ).toStrictEqual([true, true, true]); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('showExistenceFetchError')) + ).toStrictEqual([true, true, true]); + }); + + it('renders correctly in no fields state', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistInIndex: false, + fieldsExistenceStatus: ExistenceFetchStatus.failed, + }, + hookParams: { + dataViewId: dataView.id!, + allFields: [], + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('0 available fields. 0 empty fields. 0 meta fields.'); + expect(wrapper.find(FieldsAccordion)).toHaveLength(3); + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); + expect( + wrapper.find(NoFieldsCallout).map((callout) => callout.prop('fieldsExistInIndex')) + ).toStrictEqual([false, false, false]); + }); + + it('renders correctly for text-based queries (no data view)', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: null, + allFields, + onSelectedFieldFilter: (field) => field.name === 'bytes', + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('1 selected field. 28 available fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([1, 28]); + }); + + it('renders correctly when Meta gets open', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: dataView.id!, + allFields, + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 0 empty fields. 3 meta fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 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, 0, 3]); + }); + + it('renders correctly when paginated', async () => { + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams: { + dataViewId: dataView.id!, + allFields: manyFields, + }, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + expect( + wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length) + ).toStrictEqual([25, 0, 0]); + + await act(async () => { + await wrapper + .find('[data-test-subj="fieldListGroupedEmptyFields"]') + .find('button') + .first() + .simulate('click'); + await wrapper.update(); + }); + + 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]); + }); + + it('renders correctly when filtered', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onFilterField: (field: DataViewField) => field.name.startsWith('@'), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('2 available fields. 8 empty fields. 0 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onFilterField: (field: DataViewField) => field.name.startsWith('_'), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('0 available fields. 12 empty fields. 3 meta fields.'); + }); + + it('renders correctly when non-supported fields are filtered out', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onSupportedFieldFilter: (field: DataViewField) => field.aggregatable, + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('23 available fields. 104 empty fields. 3 meta fields.'); + }); + + it('renders correctly when selected fields are present', async () => { + const hookParams = { + dataViewId: dataView.id!, + allFields: manyFields, + }; + const wrapper = await mountGroupedList({ + listProps: { + ...defaultProps, + fieldsExistenceStatus: ExistenceFetchStatus.succeeded, + }, + hookParams, + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('25 available fields. 112 empty fields. 3 meta fields.'); + + await act(async () => { + await wrapper.setProps({ + hookParams: { + ...hookParams, + onSelectedFieldFilter: (field: DataViewField) => + ['@timestamp', 'bytes'].includes(field.name), + }, + }); + await wrapper.update(); + }); + + expect( + wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text() + ).toBe('2 selected fields. 25 available fields. 112 empty fields. 3 meta fields.'); + }); +}); diff --git a/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx new file mode 100644 index 0000000000000..50cc76560dc4b --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/field_list_grouped.tsx @@ -0,0 +1,269 @@ +/* + * 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 { partition, throttle } from 'lodash'; +import React, { Fragment, useCallback, 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 type { FieldListGroups, FieldListItem } from '../../types'; +import { ExistenceFetchStatus, FieldsGroupNames } from '../../types'; +import './field_list_grouped.scss'; + +const PAGINATION_SIZE = 50; + +function getDisplayedFieldsLength( + fieldGroups: FieldListGroups, + accordionState: Partial> +) { + return Object.entries(fieldGroups) + .filter(([key]) => accordionState[key]) + .reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0); +} + +export interface FieldListGroupedProps { + fieldGroups: FieldListGroups; + fieldsExistenceStatus: ExistenceFetchStatus; + fieldsExistInIndex: boolean; + renderFieldItem: FieldsAccordionProps['renderFieldItem']; + screenReaderDescriptionForSearchInputId?: string; + 'data-test-subj'?: string; +} + +function InnerFieldListGrouped({ + fieldGroups, + fieldsExistenceStatus, + fieldsExistInIndex, + renderFieldItem, + screenReaderDescriptionForSearchInputId, + 'data-test-subj': dataTestSubject = 'fieldListGrouped', +}: FieldListGroupedProps) { + const hasSyncedExistingFields = + fieldsExistenceStatus && fieldsExistenceStatus !== ExistenceFetchStatus.unknown; + + const [fieldGroupsToShow, fieldGroupsToCollapse] = partition( + Object.entries(fieldGroups), + ([, { showInAccordion }]) => showInAccordion + ); + const [pageSize, setPageSize] = useState(PAGINATION_SIZE); + const [scrollContainer, setScrollContainer] = useState(undefined); + const [accordionState, setAccordionState] = useState>>(() => + Object.fromEntries( + fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => [key, isInitiallyOpen]) + ) + ); + + const lazyScroll = useCallback(() => { + if (scrollContainer) { + const nearBottom = + scrollContainer.scrollTop + scrollContainer.clientHeight > + scrollContainer.scrollHeight * 0.9; + if (nearBottom) { + setPageSize( + Math.max( + PAGINATION_SIZE, + Math.min( + pageSize + PAGINATION_SIZE * 0.5, + getDisplayedFieldsLength(fieldGroups, accordionState) + ) + ) + ); + } + } + }, [scrollContainer, pageSize, setPageSize, fieldGroups, accordionState]); + + const paginatedFields = useMemo(() => { + let remainingItems = pageSize; + return Object.fromEntries( + fieldGroupsToShow.map(([key, fieldGroup]) => { + if (!accordionState[key] || remainingItems <= 0) { + return [key, []]; + } + const slicedFieldList = fieldGroup.fields.slice(0, remainingItems); + remainingItems = remainingItems - slicedFieldList.length; + return [key, slicedFieldList]; + }) + ); + }, [pageSize, fieldGroupsToShow, accordionState]); + + return ( +
    { + if (el && !el.dataset.dynamicScroll) { + el.dataset.dynamicScroll = 'true'; + setScrollContainer(el); + } + }} + onScroll={throttle(lazyScroll, 100)} + > +
    + {Boolean(screenReaderDescriptionForSearchInputId) && ( + +
    + {hasSyncedExistingFields + ? [ + fieldGroups.SelectedFields && + (!fieldGroups.SelectedFields?.hideIfEmpty || + fieldGroups.SelectedFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForSelectedFieldsLiveRegion', + { + defaultMessage: + '{selectedFields} selected {selectedFields, plural, one {field} other {fields}}.', + values: { + selectedFields: fieldGroups.SelectedFields?.fields?.length || 0, + }, + } + ), + fieldGroups.AvailableFields?.fields && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForAvailableFieldsLiveRegion', + { + defaultMessage: + '{availableFields} available {availableFields, plural, one {field} other {fields}}.', + values: { + availableFields: fieldGroups.AvailableFields.fields.length, + }, + } + ), + fieldGroups.PopularFields && + (!fieldGroups.PopularFields?.hideIfEmpty || + fieldGroups.PopularFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForPopularFieldsLiveRegion', + { + defaultMessage: + '{popularFields} empty {popularFields, plural, one {field} other {fields}}.', + values: { + popularFields: fieldGroups.PopularFields?.fields?.length || 0, + }, + } + ), + fieldGroups.EmptyFields && + (!fieldGroups.EmptyFields?.hideIfEmpty || + fieldGroups.EmptyFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForEmptyFieldsLiveRegion', + { + defaultMessage: + '{emptyFields} empty {emptyFields, plural, one {field} other {fields}}.', + values: { + emptyFields: fieldGroups.EmptyFields?.fields?.length || 0, + }, + } + ), + fieldGroups.MetaFields && + (!fieldGroups.MetaFields?.hideIfEmpty || + fieldGroups.MetaFields?.fields?.length > 0) && + i18n.translate( + 'unifiedFieldList.fieldListGrouped.fieldSearchForMetaFieldsLiveRegion', + { + defaultMessage: + '{metaFields} meta {metaFields, plural, one {field} other {fields}}.', + values: { + metaFields: fieldGroups.MetaFields?.fields?.length || 0, + }, + } + ), + ] + .filter(Boolean) + .join(' ') + : ''} +
    +
    + )} + {Boolean(fieldGroupsToCollapse[0]?.[1]?.fields.length) && ( + <> +
      + {fieldGroupsToCollapse.flatMap(([key, { fields }]) => + fields.map((field, index) => ( + + {renderFieldItem({ + field, + itemIndex: index, + groupIndex: 0, + groupName: key as FieldsGroupNames, + hideDetails: true, + })} + + )) + )} +
    + + + )} + {fieldGroupsToShow.map(([key, fieldGroup], index) => { + const hidden = Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length; + if (hidden) { + return null; + } + return ( + + + id={`${dataTestSubject}${key}`} + initialIsOpen={Boolean(accordionState[key])} + label={fieldGroup.title} + helpTooltip={fieldGroup.helpText} + hideDetails={fieldGroup.hideDetails} + hasLoaded={hasSyncedExistingFields} + fieldsCount={fieldGroup.fields.length} + isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length} + paginatedFields={paginatedFields[key]} + groupIndex={index + 1} + groupName={key as FieldsGroupNames} + onToggle={(open) => { + setAccordionState((s) => ({ + ...s, + [key]: open, + })); + const displayedFieldLength = getDisplayedFieldsLength(fieldGroups, { + ...accordionState, + [key]: open, + }); + setPageSize( + Math.max( + PAGINATION_SIZE, + Math.min(Math.ceil(pageSize * 1.5), displayedFieldLength) + ) + ); + }} + showExistenceFetchError={fieldsExistenceStatus === ExistenceFetchStatus.failed} + showExistenceFetchTimeout={fieldsExistenceStatus === ExistenceFetchStatus.failed} // TODO: deprecate timeout logic? + renderCallout={() => ( + + )} + renderFieldItem={renderFieldItem} + /> + + + ); + })} +
    +
    + ); +} + +export type GenericFieldListGrouped = typeof InnerFieldListGrouped; +const FieldListGrouped = React.memo(InnerFieldListGrouped) as GenericFieldListGrouped; + +// Necessary for React.lazy +// eslint-disable-next-line import/no-default-export +export default FieldListGrouped; diff --git a/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss new file mode 100644 index 0000000000000..501b27969e768 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.scss @@ -0,0 +1,8 @@ +.unifiedFieldList__fieldsAccordion__titleTooltip { + margin-right: $euiSizeXS; +} + +.unifiedFieldList__fieldsAccordion__fieldItems { + // Quick fix for making sure the shadow and focus rings are visible outside the accordion bounds + padding: $euiSizeXS; +} diff --git a/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx new file mode 100644 index 0000000000000..6c94f8a8e8335 --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.test.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { EuiLoadingSpinner, EuiNotificationBadge, EuiText } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { FieldsAccordion, FieldsAccordionProps } from './fields_accordion'; +import { FieldListItem, FieldsGroupNames } from '../../types'; + +describe('UnifiedFieldList ', () => { + let defaultProps: FieldsAccordionProps; + const paginatedFields = dataView.fields; + + beforeEach(() => { + defaultProps = { + initialIsOpen: true, + onToggle: jest.fn(), + groupIndex: 1, + groupName: FieldsGroupNames.AvailableFields, + id: 'id', + label: 'label-test', + hasLoaded: true, + fieldsCount: paginatedFields.length, + isFiltered: false, + paginatedFields, + renderCallout: () =>
    Callout
    , + renderFieldItem: ({ field }) => {field.name}, + }; + }); + + it('renders fields correctly', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(paginatedFields.length + 1); // + title + expect(wrapper.find(EuiText).first().text()).toBe(defaultProps.label); + expect(wrapper.find(EuiText).at(1).text()).toBe(paginatedFields[0].name); + expect(wrapper.find(EuiText).last().text()).toBe( + paginatedFields[paginatedFields.length - 1].name + ); + }); + + it('renders callout if no fields', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('#lens-test-callout').length).toEqual(1); + }); + + it('renders accented notificationBadge state if isFiltered', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiNotificationBadge).prop('color')).toEqual('accent'); + }); + + it('renders spinner if has not loaded', () => { + const wrapper = mountWithIntl(); + expect(wrapper.find(EuiLoadingSpinner).length).toEqual(1); + }); +}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx similarity index 50% rename from x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx rename to src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx index d6b4c73b51082..fbe548698fa9d 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.tsx +++ b/src/plugins/unified_field_list/public/components/field_list/fields_accordion.tsx @@ -1,12 +1,12 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 './datapanel.scss'; -import React, { memo, useCallback, useMemo } from 'react'; +import React, { useMemo, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiText, @@ -17,26 +17,11 @@ import { EuiIconTip, } from '@elastic/eui'; import classNames from 'classnames'; -import { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { Filter } from '@kbn/es-query'; -import type { Query } from '@kbn/es-query'; -import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; -import { UiActionsStart } from '@kbn/ui-actions-plugin/public'; -import { FieldItem } from './field_item'; -import type { DatasourceDataPanelProps, IndexPattern, IndexPatternField } from '../../types'; +import { type DataViewField } from '@kbn/data-views-plugin/common'; +import { type FieldListItem, FieldsGroupNames } from '../../types'; +import './fields_accordion.scss'; -export interface FieldItemSharedProps { - core: DatasourceDataPanelProps['core']; - fieldFormats: FieldFormatsStart; - chartsThemeService: ChartsPluginSetup['theme']; - indexPattern: IndexPattern; - highlight?: string; - query: Query; - dateRange: DatasourceDataPanelProps['dateRange']; - filters: Filter[]; -} - -export interface FieldsAccordionProps { +export interface FieldsAccordionProps { initialIsOpen: boolean; onToggle: (open: boolean) => void; id: string; @@ -44,23 +29,24 @@ export interface FieldsAccordionProps { helpTooltip?: string; hasLoaded: boolean; fieldsCount: number; + hideDetails?: boolean; isFiltered: boolean; - paginatedFields: IndexPatternField[]; - fieldProps: FieldItemSharedProps; - renderCallout: JSX.Element; - exists: (field: IndexPatternField) => 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; showExistenceFetchTimeout?: boolean; - hideDetails?: boolean; - groupIndex: number; - dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; - hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; - editField?: (name: string) => void; - removeField?: (name: string) => void; - uiActions: UiActionsStart; } -export const FieldsAccordion = memo(function InnerFieldsAccordion({ +function InnerFieldsAccordion({ initialIsOpen, onToggle, id, @@ -68,56 +54,22 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ helpTooltip, hasLoaded, fieldsCount, + hideDetails, isFiltered, + groupIndex, + groupName, paginatedFields, - fieldProps, + renderFieldItem, renderCallout, - exists, - hideDetails, showExistenceFetchError, showExistenceFetchTimeout, - groupIndex, - dropOntoWorkspace, - hasSuggestionForField, - editField, - removeField, - uiActions, -}: FieldsAccordionProps) { - const renderField = useCallback( - (field: IndexPatternField, index) => ( - - ), - [ - fieldProps, - exists, - hideDetails, - dropOntoWorkspace, - hasSuggestionForField, - groupIndex, - editField, - removeField, - uiActions, - ] - ); - +}: FieldsAccordionProps) { const renderButton = useMemo(() => { const titleClassname = classNames({ // eslint-disable-next-line @typescript-eslint/naming-convention - lnsInnerIndexPatternDataPanel__titleTooltip: !!helpTooltip, + unifiedFieldList__fieldsAccordion__titleTooltip: !!helpTooltip, }); + return ( {label} @@ -142,12 +94,12 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ if (showExistenceFetchError) { return ( @@ -156,12 +108,12 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ if (showExistenceFetchTimeout) { return ( @@ -194,12 +146,19 @@ export const FieldsAccordion = memo(function InnerFieldsAccordion({ {hasLoaded && (!!fieldsCount ? ( -
      - {paginatedFields && paginatedFields.map(renderField)} +
        + {paginatedFields && + paginatedFields.map((field, index) => ( + + {renderFieldItem({ field, itemIndex: index, groupIndex, groupName, hideDetails })} + + ))}
      ) : ( - renderCallout + renderCallout() ))} ); -}); +} + +export const FieldsAccordion = React.memo(InnerFieldsAccordion) as typeof InnerFieldsAccordion; diff --git a/src/plugins/unified_field_list/public/components/field_list/index.tsx b/src/plugins/unified_field_list/public/components/field_list/index.tsx new file mode 100755 index 0000000000000..44302a7e1c42b --- /dev/null +++ b/src/plugins/unified_field_list/public/components/field_list/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 React, { Fragment } from 'react'; +import { type DataViewField } from '@kbn/data-views-plugin/common'; +import type { FieldListGroupedProps, GenericFieldListGrouped } from './field_list_grouped'; +import { type FieldListItem } from '../../types'; + +const Fallback = () => ; + +const LazyFieldListGrouped = React.lazy( + () => import('./field_list_grouped') +) as GenericFieldListGrouped; + +function WrappedFieldListGrouped( + props: FieldListGroupedProps +) { + return ( + }> + {...props} /> + + ); +} + +export const FieldListGrouped = WrappedFieldListGrouped; +export type { FieldListGroupedProps }; diff --git a/x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx b/src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx similarity index 86% rename from x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx rename to src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx index 635c06691a733..03936a89877ba 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/no_fields_callout.test.tsx +++ b/src/plugins/unified_field_list/public/components/field_list/no_fields_callout.test.tsx @@ -1,17 +1,18 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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 React from 'react'; import { shallow } from 'enzyme'; import { NoFieldsCallout } from './no_fields_callout'; -describe('NoFieldCallout', () => { +describe('UnifiedFieldList ', () => { it('renders correctly for index with no fields', () => { - const component = shallow(); + const component = shallow(); expect(component).toMatchInlineSnapshot(` { `); }); it('renders correctly when empty with no filters/timerange reasons', () => { - const component = shallow(); + const component = shallow(); expect(component).toMatchInlineSnapshot(` { }); it('renders correctly with passed defaultNoFieldsMessage', () => { const component = shallow( - + ); expect(component).toMatchInlineSnapshot(` { it('renders properly when affected by field filter', () => { const component = shallow( - + ); expect(component).toMatchInlineSnapshot(` { it('renders correctly when affected by global filters and timerange', () => { const component = shallow( { it('renders correctly when affected by global filters and field filters', () => { const component = shallow( { it('renders correctly when affected by field filters, global filter and timerange', () => { const component = shallow( { - if (!existFieldsInIndex) { + if (!fieldsExistInIndex) { return ( @@ -44,7 +48,7 @@ export const NoFieldsCallout = ({ color="warning" title={ isAffectedByFieldFilter - ? i18n.translate('xpack.lens.indexPatterns.noFilteredFieldsLabel', { + ? i18n.translate('unifiedFieldList.fieldList.noFieldsCallout.noFilteredFieldsLabel', { defaultMessage: 'No fields match the selected filters.', }) : defaultNoFieldsMessage @@ -53,30 +57,39 @@ export const NoFieldsCallout = ({ {(isAffectedByTimerange || isAffectedByFieldFilter || isAffectedByGlobalFilter) && ( <> - {i18n.translate('xpack.lens.indexPatterns.noFields.tryText', { + {i18n.translate('unifiedFieldList.fieldList.noFieldsCallout.noFields.tryText', { defaultMessage: 'Try:', })}
        {isAffectedByTimerange && (
      • - {i18n.translate('xpack.lens.indexPatterns.noFields.extendTimeBullet', { - defaultMessage: 'Extending the time range', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.extendTimeBullet', + { + defaultMessage: 'Extending the time range', + } + )}
      • )} {isAffectedByFieldFilter && (
      • - {i18n.translate('xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet', { - defaultMessage: 'Using different field filters', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.fieldTypeFilterBullet', + { + defaultMessage: 'Using different field filters', + } + )}
      • )} {isAffectedByGlobalFilter && (
      • - {i18n.translate('xpack.lens.indexPatterns.noFields.globalFiltersBullet', { - defaultMessage: 'Changing the global filters', - })} + {i18n.translate( + 'unifiedFieldList.fieldList.noFieldsCallout.noFields.globalFiltersBullet', + { + defaultMessage: 'Changing the global filters', + } + )}
      • )}
      diff --git a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx index 07d35b78b58a2..b3600dc9f3971 100755 --- a/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx +++ b/src/plugins/unified_field_list/public/components/field_stats/field_stats.tsx @@ -8,8 +8,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - DataView, - DataViewField, + type DataView, + type DataViewField, ES_FIELD_TYPES, getEsQueryConfig, KBN_FIELD_TYPES, diff --git a/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx b/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx new file mode 100644 index 0000000000000..7a27a1468213d --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_existing_fields.test.tsx @@ -0,0 +1,536 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { createStubDataView, stubFieldSpecMap } from '@kbn/data-plugin/public/stubs'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { UI_SETTINGS } from '@kbn/data-plugin/common'; +import { + useExistingFieldsFetcher, + useExistingFieldsReader, + resetExistingFieldsCache, + type ExistingFieldsFetcherParams, + ExistingFieldsReader, +} from './use_existing_fields'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { coreMock } from '@kbn/core/public/mocks'; +import * as ExistingFieldsServiceApi from '../services/field_existing/load_field_existing'; +import { ExistenceFetchStatus } from '../types'; + +const dslQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; +const rollupAggsMock = { + date_histogram: { + '@timestamp': { + agg: 'date_histogram', + fixed_interval: '20m', + delay: '10m', + time_zone: 'UTC', + }, + }, +}; + +jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting').mockImplementation(async () => ({ + indexPatternTitle: 'test', + existingFieldNames: [], +})); + +describe('UnifiedFieldList useExistingFields', () => { + let mockedServices: ExistingFieldsFetcherParams['services']; + const anotherDataView = createStubDataView({ + spec: { + id: 'another-data-view', + title: 'logstash-0', + fields: stubFieldSpecMap, + }, + }); + const dataViewWithRestrictions = createStubDataView({ + spec: { + id: 'another-data-view-with-restrictions', + title: 'logstash-1', + fields: stubFieldSpecMap, + typeMeta: { + aggs: rollupAggsMock, + }, + }, + }); + jest.spyOn(dataViewWithRestrictions, 'getAggregationRestrictions'); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + const core = coreMock.createStart(); + mockedServices = { + dataViews, + data: dataPluginMock.createStartContract(), + core, + }; + + core.uiSettings.get.mockImplementation((key: string) => { + if (key === UI_SETTINGS.META_FIELDS) { + return ['_id']; + } + }); + + dataViews.get.mockImplementation(async (id: string) => { + return [dataView, anotherDataView, dataViewWithRestrictions].find((dw) => dw.id === id)!; + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear(); + (dataViewWithRestrictions.getAggregationRestrictions as jest.Mock).mockClear(); + resetExistingFieldsCache(); + }); + + it('should work correctly based on the specified data view', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [dataView.fields[0].name], + }; + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + // has existence info for the loaded data view => works more restrictive + expect(hookReader.result.current.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(false); + expect(hookReader.result.current.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(hookReader.result.current.hasFieldData(dataViewId, dataView.fields[1].name)).toBe(false); + expect(hookReader.result.current.getFieldsExistenceStatus(dataViewId)).toBe( + ExistenceFetchStatus.succeeded + ); + + // does not have existence info => works less restrictive + const anotherDataViewId = 'test-id'; + expect(hookReader.result.current.isFieldsExistenceInfoUnavailable(anotherDataViewId)).toBe( + false + ); + expect(hookReader.result.current.hasFieldData(anotherDataViewId, dataView.fields[0].name)).toBe( + true + ); + expect(hookReader.result.current.hasFieldData(anotherDataViewId, dataView.fields[1].name)).toBe( + true + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataViewId)).toBe( + ExistenceFetchStatus.unknown + ); + }); + + it('should work correctly with multiple readers', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [dataView.fields[0].name], + }; + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader1 = renderHook(useExistingFieldsReader); + const hookReader2 = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalled(); + + const checkResults = (currentResult: ExistingFieldsReader) => { + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(false); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[1].name)).toBe(false); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe( + ExistenceFetchStatus.succeeded + ); + }; + + // both readers should get the same results + + checkResults(hookReader1.result.current); + checkResults(hookReader2.result.current); + + // info should be persisted even if the fetcher was unmounted + + hookFetcher.unmount(); + + checkResults(hookReader1.result.current); + checkResults(hookReader2.result.current); + }); + + it('should work correctly if load fails', async () => { + const dataViewId = dataView.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalled(); + + const currentResult = hookReader.result.current; + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataView.fields[0].name)).toBe(true); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe(ExistenceFetchStatus.failed); + }); + + it('should work correctly for multiple data views', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataView, anotherDataView, dataViewWithRestrictions], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + const currentResult = hookReader.result.current; + + expect(currentResult.isFieldsExistenceInfoUnavailable(dataView.id!)).toBe(false); + expect(currentResult.isFieldsExistenceInfoUnavailable(anotherDataView.id!)).toBe(false); + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewWithRestrictions.id!)).toBe(true); + expect(currentResult.isFieldsExistenceInfoUnavailable('test-id')).toBe(false); + + expect(currentResult.hasFieldData(dataView.id!, dataView.fields[0].name)).toBe(true); + expect(currentResult.hasFieldData(dataView.id!, dataView.fields[1].name)).toBe(false); + + expect(currentResult.hasFieldData(anotherDataView.id!, anotherDataView.fields[0].name)).toBe( + true + ); + expect(currentResult.hasFieldData(anotherDataView.id!, anotherDataView.fields[1].name)).toBe( + false + ); + + expect( + currentResult.hasFieldData( + dataViewWithRestrictions.id!, + dataViewWithRestrictions.fields[0].name + ) + ).toBe(true); + expect( + currentResult.hasFieldData( + dataViewWithRestrictions.id!, + dataViewWithRestrictions.fields[1].name + ) + ).toBe(true); + expect(currentResult.hasFieldData('test-id', 'test-field')).toBe(true); + + expect(currentResult.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus(dataViewWithRestrictions.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(currentResult.getFieldsExistenceStatus('test-id')).toBe(ExistenceFetchStatus.unknown); + + expect(dataViewWithRestrictions.getAggregationRestrictions).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + }); + + it('should work correctly for data views with restrictions', async () => { + const dataViewId = dataViewWithRestrictions.id!; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); + + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: { + dataViews: [dataViewWithRestrictions], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + await hookFetcher.waitFor(() => !hookFetcher.result.current.isProcessing); + + expect(dataViewWithRestrictions.getAggregationRestrictions).toHaveBeenCalled(); + expect(ExistingFieldsServiceApi.loadFieldExisting).not.toHaveBeenCalled(); + + const currentResult = hookReader.result.current; + expect(currentResult.isFieldsExistenceInfoUnavailable(dataViewId)).toBe(true); + expect(currentResult.hasFieldData(dataViewId, dataViewWithRestrictions.fields[0].name)).toBe( + true + ); + expect(currentResult.getFieldsExistenceStatus(dataViewId)).toBe(ExistenceFetchStatus.succeeded); + }); + + it('should work correctly for when data views are changed', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.unknown + ); + + hookFetcher.rerender({ + ...params, + dataViews: [dataView, anotherDataView], + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: anotherDataView, + timeFieldName: anotherDataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + expect(hookReader.result.current.getFieldsExistenceStatus(anotherDataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + }); + + it('should work correctly for when params are changed', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView: currentDataView }) => { + return { + existingFieldNames: [currentDataView.fields[0].name], + }; + } + ); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + + hookFetcher.rerender({ + ...params, + fromDate: '2021-01-01', + toDate: '2022-01-01', + query: { query: 'test', language: 'kuery' }, + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2021-01-01', + toDate: '2022-01-01', + dslQuery: { + bool: { + filter: [ + { + multi_match: { + lenient: true, + query: 'test', + type: 'best_fields', + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + }); + + it('should call onNoData callback only once', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: ['_id'], + }; + }); + + const params: ExistingFieldsFetcherParams = { + dataViews: [dataView], + services: mockedServices, + fromDate: '2019-01-01', + toDate: '2020-01-01', + query: { query: '', language: 'lucene' }, + filters: [], + onNoData: jest.fn(), + }; + const hookFetcher = renderHook(useExistingFieldsFetcher, { + initialProps: params, + }); + + const hookReader = renderHook(useExistingFieldsReader); + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(hookReader.result.current.getFieldsExistenceStatus(dataView.id!)).toBe( + ExistenceFetchStatus.succeeded + ); + + expect(params.onNoData).toHaveBeenCalledWith(dataView.id); + expect(params.onNoData).toHaveBeenCalledTimes(1); + + hookFetcher.rerender({ + ...params, + fromDate: '2021-01-01', + toDate: '2022-01-01', + }); + + await hookFetcher.waitForNextUpdate(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fromDate: '2021-01-01', + toDate: '2022-01-01', + dslQuery, + dataView, + timeFieldName: dataView.timeFieldName, + }) + ); + + expect(params.onNoData).toHaveBeenCalledTimes(1); // still 1 time + }); +}); diff --git a/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts b/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts new file mode 100644 index 0000000000000..4c3a9e4c4cdde --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_existing_fields.ts @@ -0,0 +1,364 @@ +/* + * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { htmlIdGenerator } from '@elastic/eui'; +import { BehaviorSubject } from 'rxjs'; +import { CoreStart } from '@kbn/core/public'; +import type { AggregateQuery, EsQueryConfig, Filter, Query } from '@kbn/es-query'; +import { + DataPublicPluginStart, + DataViewsContract, + getEsQueryConfig, +} from '@kbn/data-plugin/public'; +import { type DataView } from '@kbn/data-plugin/common'; +import { loadFieldExisting } from '../services/field_existing'; +import { ExistenceFetchStatus } from '../types'; + +const getBuildEsQueryAsync = async () => (await import('@kbn/es-query')).buildEsQuery; +const generateId = htmlIdGenerator(); + +export interface ExistingFieldsInfo { + fetchStatus: ExistenceFetchStatus; + existingFieldsByFieldNameMap: Record; + numberOfFetches: number; + hasDataViewRestrictions?: boolean; +} + +export interface ExistingFieldsFetcherParams { + dataViews: DataView[]; + fromDate: string; + toDate: string; + query: Query | AggregateQuery; + filters: Filter[]; + services: { + core: Pick; + data: DataPublicPluginStart; + dataViews: DataViewsContract; + }; + onNoData?: (dataViewId: string) => unknown; +} + +type ExistingFieldsByDataViewMap = Record; + +export interface ExistingFieldsFetcher { + refetchFieldsExistenceInfo: (dataViewId?: string) => Promise; + isProcessing: boolean; +} + +export interface ExistingFieldsReader { + hasFieldData: (dataViewId: string, fieldName: string) => boolean; + getFieldsExistenceStatus: (dataViewId: string) => ExistenceFetchStatus; + isFieldsExistenceInfoUnavailable: (dataViewId: string) => boolean; +} + +const initialData: ExistingFieldsByDataViewMap = {}; +const unknownInfo: ExistingFieldsInfo = { + fetchStatus: ExistenceFetchStatus.unknown, + existingFieldsByFieldNameMap: {}, + numberOfFetches: 0, +}; + +const globalMap$ = new BehaviorSubject(initialData); // for syncing between hooks +let lastFetchId: string = ''; // persist last fetch id to skip older requests/responses if any + +export const useExistingFieldsFetcher = ( + params: ExistingFieldsFetcherParams +): ExistingFieldsFetcher => { + const mountedRef = useRef(true); + const [activeRequests, setActiveRequests] = useState(0); + const isProcessing = activeRequests > 0; + + const fetchFieldsExistenceInfo = useCallback( + async ({ + dataViewId, + query, + filters, + fromDate, + toDate, + services: { dataViews, data, core }, + onNoData, + fetchId, + }: ExistingFieldsFetcherParams & { + dataViewId: string | undefined; + fetchId: string; + }): Promise => { + if (!dataViewId || !query || !filters) { + return; + } + + const currentInfo = globalMap$.getValue()?.[dataViewId]; + + if (!mountedRef.current) { + return; + } + + const numberOfFetches = (currentInfo?.numberOfFetches ?? 0) + 1; + const dataView = await dataViews.get(dataViewId); + + if (!dataView?.title) { + return; + } + + setActiveRequests((value) => value + 1); + + const hasRestrictions = Boolean(dataView.getAggregationRestrictions?.()); + const info: ExistingFieldsInfo = { + ...unknownInfo, + numberOfFetches, + }; + + if (hasRestrictions) { + info.fetchStatus = ExistenceFetchStatus.succeeded; + info.hasDataViewRestrictions = true; + } else { + try { + const result = await loadFieldExisting({ + dslQuery: await buildSafeEsQuery( + dataView, + query, + filters, + getEsQueryConfig(core.uiSettings) + ), + fromDate, + toDate, + timeFieldName: dataView.timeFieldName, + data, + uiSettingsClient: core.uiSettings, + dataViewsService: dataViews, + dataView, + }); + + const existingFieldNames = result?.existingFieldNames || []; + + if ( + onNoData && + numberOfFetches === 1 && + !existingFieldNames.filter((fieldName) => !dataView?.metaFields?.includes(fieldName)) + .length + ) { + onNoData(dataViewId); + } + + info.existingFieldsByFieldNameMap = booleanMap(existingFieldNames); + info.fetchStatus = ExistenceFetchStatus.succeeded; + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + info.fetchStatus = ExistenceFetchStatus.failed; + } + } + + // skip redundant and older results + if (mountedRef.current && fetchId === lastFetchId) { + globalMap$.next({ + ...globalMap$.getValue(), + [dataViewId]: info, + }); + } + + setActiveRequests((value) => value - 1); + }, + [mountedRef, setActiveRequests] + ); + + const dataViewsHash = getDataViewsHash(params.dataViews); + // const prevParamsRef = useRef([]); + const refetchFieldsExistenceInfo = useCallback( + async (dataViewId?: string) => { + // const currentParams = [ + // fetchFieldsExistenceInfo, + // dataViewsHash, + // params.query, + // params.filters, + // params.fromDate, + // params.toDate, + // ]; + // + // currentParams.forEach((param, index) => { + // if (param !== prevParamsRef.current[index]) { + // console.log('different param', param, prevParamsRef.current[index]); + // } + // }); + // + // prevParamsRef.current = currentParams; + // console.log('refetch triggered', { dataViewId }); + const fetchId = generateId(); + lastFetchId = fetchId; + // refetch only for the specified data view + if (dataViewId) { + await fetchFieldsExistenceInfo({ + fetchId, + dataViewId, + ...params, + }); + return; + } + // refetch for all mentioned data views + await Promise.all( + params.dataViews.map((dataView) => + fetchFieldsExistenceInfo({ + fetchId, + dataViewId: dataView.id, + ...params, + }) + ) + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + fetchFieldsExistenceInfo, + dataViewsHash, + params.query, + params.filters, + params.fromDate, + params.toDate, + ] + ); + + useEffect(() => { + refetchFieldsExistenceInfo(); + }, [refetchFieldsExistenceInfo]); + + useEffect(() => { + return () => { + mountedRef.current = false; + globalMap$.next({}); // reset the cache (readers will continue using their own data slice until they are unmounted too) + }; + }, [mountedRef]); + + return useMemo( + () => ({ + refetchFieldsExistenceInfo, + isProcessing, + }), + [refetchFieldsExistenceInfo, isProcessing] + ); +}; + +export const useExistingFieldsReader: () => ExistingFieldsReader = () => { + const mountedRef = useRef(true); + const [existingFieldsByDataViewMap, setExistingFieldsByDataViewMap] = + useState(globalMap$.getValue()); + + useEffect(() => { + const subscription = globalMap$.subscribe((data) => { + if (mountedRef.current && Object.keys(data).length > 0) { + setExistingFieldsByDataViewMap((savedData) => ({ + ...savedData, + ...data, + })); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [setExistingFieldsByDataViewMap, mountedRef]); + + const hasFieldData = useCallback( + (dataViewId: string, fieldName: string) => { + const info = existingFieldsByDataViewMap[dataViewId]; + + if (info?.fetchStatus === ExistenceFetchStatus.succeeded) { + return ( + info?.hasDataViewRestrictions || Boolean(info?.existingFieldsByFieldNameMap[fieldName]) + ); + } + + return true; + }, + [existingFieldsByDataViewMap] + ); + + const getFieldsExistenceInfo = useCallback( + (dataViewId: string) => { + return dataViewId ? existingFieldsByDataViewMap[dataViewId] : unknownInfo; + }, + [existingFieldsByDataViewMap] + ); + + const getFieldsExistenceStatus = useCallback( + (dataViewId: string): ExistenceFetchStatus => { + return getFieldsExistenceInfo(dataViewId)?.fetchStatus || ExistenceFetchStatus.unknown; + }, + [getFieldsExistenceInfo] + ); + + const isFieldsExistenceInfoUnavailable = useCallback( + (dataViewId: string): boolean => { + const info = getFieldsExistenceInfo(dataViewId); + return Boolean( + info?.fetchStatus === ExistenceFetchStatus.failed || info?.hasDataViewRestrictions + ); + }, + [getFieldsExistenceInfo] + ); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, [mountedRef]); + + return useMemo( + () => ({ + hasFieldData, + getFieldsExistenceStatus, + isFieldsExistenceInfoUnavailable, + }), + [hasFieldData, getFieldsExistenceStatus, isFieldsExistenceInfoUnavailable] + ); +}; + +export const resetExistingFieldsCache = () => { + globalMap$.next(initialData); +}; + +function getDataViewsHash(dataViews: DataView[]): string { + return ( + dataViews + // From Lens it's coming as IndexPattern type and not the real DataView type + .map( + (dataView) => + `${dataView.id}:${dataView.title}:${dataView.timeFieldName || 'no-timefield'}:${ + dataView.fields?.length ?? 0 // adding a field will also trigger a refetch of fields existence data + }` + ) + .join(',') + ); +} + +// Wrapper around buildEsQuery, handling errors (e.g. because a query can't be parsed) by +// returning a query dsl object not matching anything +async function buildSafeEsQuery( + dataView: DataView, + query: Query | AggregateQuery, + filters: Filter[], + queryConfig: EsQueryConfig +) { + const buildEsQuery = await getBuildEsQueryAsync(); + try { + return buildEsQuery(dataView, query, filters, queryConfig); + } catch (e) { + return { + bool: { + must_not: { + match_all: {}, + }, + }, + }; + } +} + +function booleanMap(keys: string[]) { + return keys.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); +} diff --git a/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx new file mode 100644 index 0000000000000..f4f30036205e0 --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.test.tsx @@ -0,0 +1,292 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { + stubDataViewWithoutTimeField, + stubLogstashDataView as dataView, +} from '@kbn/data-views-plugin/common/data_view.stub'; +import { createStubDataView, stubFieldSpecMap } from '@kbn/data-plugin/public/stubs'; +import { DataViewField } from '@kbn/data-views-plugin/common'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { type GroupedFieldsParams, useGroupedFields } from './use_grouped_fields'; +import { ExistenceFetchStatus, FieldListGroups, FieldsGroupNames } from '../types'; + +describe('UnifiedFieldList useGroupedFields()', () => { + let mockedServices: GroupedFieldsParams['services']; + const allFields = dataView.fields; + const anotherDataView = createStubDataView({ + spec: { + id: 'another-data-view', + title: 'logstash-0', + fields: stubFieldSpecMap, + }, + }); + + beforeEach(() => { + const dataViews = dataViewPluginMocks.createStartContract(); + mockedServices = { + dataViews, + }; + + dataViews.get.mockImplementation(async (id: string) => { + return [dataView, stubDataViewWithoutTimeField].find((dw) => dw.id === id)!; + }); + }); + + it('should work correctly for no data', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields: [], + 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-0', + 'EmptyFields-0', + 'MetaFields-0', + ]); + }); + + it('should work correctly with fields', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + 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', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly when filtered', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onFilterField: (field: DataViewField) => field.name.startsWith('@'), + }) + ); + + 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-2', + 'EmptyFields-0', + 'MetaFields-0', + ]); + }); + + it('should work correctly when custom unsupported fields are skipped', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onSupportedFieldFilter: (field: DataViewField) => field.aggregatable, + }) + ); + + 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-23', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly when selected fields are present', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onSelectedFieldFilter: (field: DataViewField) => + ['bytes', 'extension', '_id', '@timestamp'].includes(field.name), + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect( + Object.keys(fieldGroups!).map( + (key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}` + ) + ).toStrictEqual([ + 'SpecialFields-0', + 'SelectedFields-4', + 'PopularFields-0', + 'AvailableFields-25', + 'EmptyFields-0', + 'MetaFields-3', + ]); + }); + + it('should work correctly for text-based queries (no data view)', async () => { + const { result } = renderHook(() => + useGroupedFields({ + dataViewId: null, + allFields, + services: mockedServices, + }) + ); + + 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-28', + 'MetaFields-0', + ]); + }); + + it('should work correctly when details are overwritten', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGroupedFields({ + dataViewId: dataView.id!, + allFields, + services: mockedServices, + onOverrideFieldGroupDetails: (groupName) => { + if (groupName === FieldsGroupNames.SelectedFields) { + return { + helpText: 'test', + }; + } + }, + }) + ); + + await waitForNextUpdate(); + + const fieldGroups = result.current.fieldGroups; + + expect(fieldGroups[FieldsGroupNames.SelectedFields]?.helpText).toBe('test'); + expect(fieldGroups[FieldsGroupNames.AvailableFields]?.helpText).not.toBe('test'); + }); + + it('should work correctly when changing a data view and existence info is available only for one of them', async () => { + const knownDataViewId = dataView.id!; + let fieldGroups: FieldListGroups; + const props: GroupedFieldsParams = { + dataViewId: dataView.id!, + allFields, + services: mockedServices, + fieldsExistenceReader: { + hasFieldData: (dataViewId, fieldName) => { + return dataViewId === knownDataViewId && ['bytes', 'extension'].includes(fieldName); + }, + getFieldsExistenceStatus: (dataViewId) => + dataViewId === knownDataViewId + ? ExistenceFetchStatus.succeeded + : ExistenceFetchStatus.unknown, + isFieldsExistenceInfoUnavailable: (dataViewId) => dataViewId !== knownDataViewId, + }, + }; + + const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, { + initialProps: props, + }); + await waitForNextUpdate(); + + 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-2', + 'EmptyFields-23', + 'MetaFields-3', + ]); + + rerender({ + ...props, + dataViewId: anotherDataView.id!, + allFields: anotherDataView.fields, + }); + + await waitForNextUpdate(); + + 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-8', + 'MetaFields-0', + ]); + }); + + // TODO: add a test for popular fields +}); diff --git a/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts new file mode 100644 index 0000000000000..57efa90e07dcc --- /dev/null +++ b/src/plugins/unified_field_list/public/hooks/use_grouped_fields.ts @@ -0,0 +1,306 @@ +/* + * 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 { groupBy } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common'; +import { type DataViewsContract } from '@kbn/data-views-plugin/public'; +import { + type FieldListGroups, + type FieldsGroupDetails, + type FieldsGroup, + type FieldListItem, + FieldsGroupNames, +} from '../types'; +import { type ExistingFieldsReader } from './use_existing_fields'; + +export interface GroupedFieldsParams { + dataViewId: string | null; // `null` is for text-based queries + allFields: T[]; + services: { + dataViews: DataViewsContract; + }; + fieldsExistenceReader?: ExistingFieldsReader; + isAffectedByGlobalFilter?: boolean; + popularFieldsLimit?: number; + sortedSelectedFields?: T[]; + onOverrideFieldGroupDetails?: ( + groupName: FieldsGroupNames + ) => Partial | undefined | null; + onSupportedFieldFilter?: (field: T) => boolean; + onSelectedFieldFilter?: (field: T) => boolean; + onFilterField?: (field: T) => boolean; +} + +export interface GroupedFieldsResult { + fieldGroups: FieldListGroups; +} + +export function useGroupedFields({ + dataViewId, + allFields, + services, + fieldsExistenceReader, + isAffectedByGlobalFilter = false, + popularFieldsLimit, + sortedSelectedFields, + onOverrideFieldGroupDetails, + onSupportedFieldFilter, + onSelectedFieldFilter, + onFilterField, +}: GroupedFieldsParams): GroupedFieldsResult { + const [dataView, setDataView] = useState(null); + const isAffectedByTimeFilter = Boolean(dataView?.timeFieldName); + const fieldsExistenceInfoUnavailable: boolean = dataViewId + ? fieldsExistenceReader?.isFieldsExistenceInfoUnavailable(dataViewId) ?? false + : true; + const hasFieldDataHandler = + dataViewId && fieldsExistenceReader + ? fieldsExistenceReader.hasFieldData + : hasFieldDataByDefault; + + useEffect(() => { + const getDataView = async () => { + if (dataViewId) { + setDataView(await services.dataViews.get(dataViewId)); + } + }; + getDataView(); + // 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]); + + const unfilteredFieldGroups: FieldListGroups = useMemo(() => { + const containsData = (field: T) => { + if (!dataViewId || !dataView) { + return true; + } + const overallField = dataView.getFieldByName?.(field.name); + return Boolean(overallField && hasFieldDataHandler(dataViewId, overallField.name)); + }; + + const fields = allFields || []; + const allSupportedTypesFields = onSupportedFieldFilter + ? fields.filter(onSupportedFieldFilter) + : fields; + const sortedFields = [...allSupportedTypesFields].sort(sortFields); + const groupedFields = { + ...getDefaultFieldGroups(), + ...groupBy(sortedFields, (field) => { + if (field.type === 'document') { + return 'specialFields'; + } else if (dataView?.metaFields?.includes(field.name)) { + return 'metaFields'; + } else if (containsData(field)) { + return 'availableFields'; + } else return 'emptyFields'; + }), + }; + const selectedFields = + sortedSelectedFields || + (onSelectedFieldFilter ? sortedFields.filter(onSelectedFieldFilter) : []); + const popularFields = popularFieldsLimit + ? sortedFields + .filter((field) => field.count && field.type !== '_source' && containsData(field)) + .sort((a: T, b: T) => (b.count || 0) - (a.count || 0)) + .slice(0, popularFieldsLimit) + : []; + + let fieldGroupDefinitions: FieldListGroups = { + SpecialFields: { + fields: groupedFields.specialFields, + fieldCount: groupedFields.specialFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: false, + title: '', + hideDetails: true, + }, + SelectedFields: { + fields: selectedFields, + fieldCount: selectedFields.length, + isInitiallyOpen: true, + showInAccordion: true, + title: i18n.translate('unifiedFieldList.useGroupedFields.selectedFieldsLabel', { + defaultMessage: 'Selected fields', + }), + 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', + }), + isAffectedByGlobalFilter, + isAffectedByTimeFilter, + hideDetails: false, + hideIfEmpty: true, + }, + AvailableFields: { + fields: groupedFields.availableFields, + fieldCount: groupedFields.availableFields.length, + isInitiallyOpen: true, + showInAccordion: true, + title: + dataViewId && fieldsExistenceInfoUnavailable + ? i18n.translate('unifiedFieldList.useGroupedFields.allFieldsLabel', { + defaultMessage: 'All fields', + }) + : i18n.translate('unifiedFieldList.useGroupedFields.availableFieldsLabel', { + defaultMessage: 'Available fields', + }), + isAffectedByGlobalFilter, + isAffectedByTimeFilter, + // Show details on timeout but not failure + // hideDetails: fieldsExistenceInfoUnavailable && !existenceFetchTimeout, // TODO: is this check still necessary? + hideDetails: fieldsExistenceInfoUnavailable, + defaultNoFieldsMessage: i18n.translate( + 'unifiedFieldList.useGroupedFields.noAvailableDataLabel', + { + defaultMessage: `There are no available fields that contain data.`, + } + ), + }, + EmptyFields: { + fields: groupedFields.emptyFields, + fieldCount: groupedFields.emptyFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: true, + hideDetails: false, + hideIfEmpty: !dataViewId, + title: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabel', { + defaultMessage: 'Empty fields', + }), + 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, + fieldCount: groupedFields.metaFields.length, + isAffectedByGlobalFilter: false, + isAffectedByTimeFilter: false, + isInitiallyOpen: false, + showInAccordion: true, + hideDetails: false, + hideIfEmpty: !dataViewId, + title: i18n.translate('unifiedFieldList.useGroupedFields.metaFieldsLabel', { + defaultMessage: 'Meta fields', + }), + defaultNoFieldsMessage: i18n.translate( + 'unifiedFieldList.useGroupedFields.noMetaDataLabel', + { + defaultMessage: `There are no meta fields.`, + } + ), + }, + }; + + // do not show empty field accordion if there is no existence information + if (fieldsExistenceInfoUnavailable) { + delete fieldGroupDefinitions.EmptyFields; + } + + if (onOverrideFieldGroupDetails) { + fieldGroupDefinitions = Object.keys(fieldGroupDefinitions).reduce>( + (definitions, name) => { + const groupName = name as FieldsGroupNames; + const group: FieldsGroup | undefined = fieldGroupDefinitions[groupName]; + if (group) { + definitions[groupName] = { + ...group, + ...(onOverrideFieldGroupDetails(groupName) || {}), + }; + } + return definitions; + }, + {} as FieldListGroups + ); + } + + return fieldGroupDefinitions; + }, [ + allFields, + onSupportedFieldFilter, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + dataView, + dataViewId, + hasFieldDataHandler, + fieldsExistenceInfoUnavailable, + isAffectedByGlobalFilter, + isAffectedByTimeFilter, + popularFieldsLimit, + sortedSelectedFields, + ]); + + const fieldGroups: FieldListGroups = useMemo(() => { + if (!onFilterField) { + return unfilteredFieldGroups; + } + + return Object.fromEntries( + Object.entries(unfilteredFieldGroups).map(([name, group]) => [ + name, + { ...group, fields: group.fields.filter(onFilterField) }, + ]) + ) as FieldListGroups; + }, [unfilteredFieldGroups, onFilterField]); + + return useMemo( + () => ({ + fieldGroups, + }), + [fieldGroups] + ); +} + +function sortFields(fieldA: T, fieldB: T) { + return (fieldA.displayName || fieldA.name).localeCompare( + fieldB.displayName || fieldB.name, + undefined, + { + sensitivity: 'base', + } + ); +} + +function hasFieldDataByDefault(): boolean { + return true; +} + +function getDefaultFieldGroups() { + return { + specialFields: [], + availableFields: [], + emptyFields: [], + metaFields: [], + }; +} diff --git a/src/plugins/unified_field_list/public/index.ts b/src/plugins/unified_field_list/public/index.ts index 2ada1027ee97a..94abf51566463 100755 --- a/src/plugins/unified_field_list/public/index.ts +++ b/src/plugins/unified_field_list/public/index.ts @@ -14,6 +14,7 @@ export type { NumberStatsResult, TopValuesResult, } from '../common/types'; +export { FieldListGrouped, type FieldListGroupedProps } from './components/field_list'; export type { FieldStatsProps, FieldStatsServices } from './components/field_stats'; export { FieldStats } from './components/field_stats'; export { @@ -44,4 +45,23 @@ export type { UnifiedFieldListPluginSetup, UnifiedFieldListPluginStart, AddFieldFilterHandler, + FieldListGroups, + FieldsGroupDetails, } from './types'; +export { ExistenceFetchStatus, FieldsGroupNames } from './types'; + +export { + useExistingFieldsFetcher, + useExistingFieldsReader, + resetExistingFieldsCache, + type ExistingFieldsInfo, + type ExistingFieldsFetcherParams, + type ExistingFieldsFetcher, + type ExistingFieldsReader, +} from './hooks/use_existing_fields'; + +export { + useGroupedFields, + type GroupedFieldsParams, + type GroupedFieldsResult, +} from './hooks/use_grouped_fields'; diff --git a/src/plugins/unified_field_list/public/services/field_existing/index.ts b/src/plugins/unified_field_list/public/services/field_existing/index.ts index 56be726b7c90f..6541afb4673bb 100644 --- a/src/plugins/unified_field_list/public/services/field_existing/index.ts +++ b/src/plugins/unified_field_list/public/services/field_existing/index.ts @@ -6,4 +6,9 @@ * Side Public License, v 1. */ -export { loadFieldExisting } from './load_field_existing'; +import type { LoadFieldExistingHandler } from './load_field_existing'; + +export const loadFieldExisting: LoadFieldExistingHandler = async (params) => { + const { loadFieldExisting: loadFieldExistingHandler } = await import('./load_field_existing'); + return await loadFieldExistingHandler(params); +}; diff --git a/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts b/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts index 79b2b056c6062..f8e369838c51a 100644 --- a/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts +++ b/src/plugins/unified_field_list/public/services/field_existing/load_field_existing.ts @@ -24,7 +24,12 @@ interface FetchFieldExistenceParams { uiSettingsClient: IUiSettingsClient; } -export async function loadFieldExisting({ +export type LoadFieldExistingHandler = (params: FetchFieldExistenceParams) => Promise<{ + existingFieldNames: string[]; + indexPatternTitle: string; +}>; + +export const loadFieldExisting: LoadFieldExistingHandler = async ({ data, dslQuery, fromDate, @@ -33,7 +38,7 @@ export async function loadFieldExisting({ dataViewsService, uiSettingsClient, dataView, -}: FetchFieldExistenceParams) { +}) => { const includeFrozen = uiSettingsClient.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN); const useSampling = uiSettingsClient.get(FIELD_EXISTENCE_SETTING); const metaFields = uiSettingsClient.get(UI_SETTINGS.META_FIELDS); @@ -53,4 +58,4 @@ export async function loadFieldExisting({ return response.rawResponse; }, }); -} +}; diff --git a/src/plugins/unified_field_list/public/types.ts b/src/plugins/unified_field_list/public/types.ts index f7a712534d59d..ec0afadb9405c 100755 --- a/src/plugins/unified_field_list/public/types.ts +++ b/src/plugins/unified_field_list/public/types.ts @@ -19,3 +19,46 @@ export type AddFieldFilterHandler = ( value: unknown, type: '+' | '-' ) => void; + +export enum ExistenceFetchStatus { + failed = 'failed', + succeeded = 'succeeded', + unknown = 'unknown', +} + +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', +} + +export interface FieldsGroupDetails { + showInAccordion: boolean; + isInitiallyOpen: boolean; + title: string; + helpText?: string; + isAffectedByGlobalFilter: boolean; + isAffectedByTimeFilter: boolean; + hideDetails?: boolean; + defaultNoFieldsMessage?: string; + hideIfEmpty?: boolean; +} + +export interface FieldsGroup extends FieldsGroupDetails { + fields: T[]; + fieldCount: number; +} + +export type FieldListGroups = { + [key in FieldsGroupNames]?: FieldsGroup; +}; diff --git a/x-pack/plugins/lens/common/types.ts b/x-pack/plugins/lens/common/types.ts index 9056e58eef1eb..bbefe8388a07c 100644 --- a/x-pack/plugins/lens/common/types.ts +++ b/x-pack/plugins/lens/common/types.ts @@ -20,11 +20,6 @@ export type { OriginalColumn } from './expressions/map_to_columns'; export type FormatFactory = (mapping?: SerializedFieldFormat) => IFieldFormat; -export interface ExistingFields { - indexPatternTitle: string; - existingFieldNames: string[]; -} - export interface DateRange { fromDate: string; toDate: string; diff --git a/x-pack/plugins/lens/public/data_views_service/loader.test.ts b/x-pack/plugins/lens/public/data_views_service/loader.test.ts index 97ded75233cda..e7e2bab166a70 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.test.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.test.ts @@ -5,22 +5,10 @@ * 2.0. */ -import { DataViewsContract, DataViewSpec, FieldSpec } from '@kbn/data-views-plugin/public'; -import { IndexPattern, IndexPatternField } from '../types'; -import { - ensureIndexPattern, - loadIndexPatternRefs, - loadIndexPatterns, - syncExistingFields, -} from './loader'; +import { DataViewsContract } from '@kbn/data-views-plugin/public'; +import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader'; import { sampleIndexPatterns, mockDataViewsService } from './mocks'; import { documentField } from '../datasources/form_based/document_field'; -import { coreMock } from '@kbn/core/public/mocks'; -import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import type { DataView } from '@kbn/data-views-plugin/public'; -import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { UI_SETTINGS } from '@kbn/data-plugin/public'; -import { createHttpFetchError } from '@kbn/core-http-browser-mocks'; describe('loader', () => { describe('loadIndexPatternRefs', () => { @@ -266,218 +254,4 @@ describe('loader', () => { expect(onError).not.toHaveBeenCalled(); }); }); - - describe('syncExistingFields', () => { - const core = coreMock.createStart(); - const dataViews = dataViewPluginMocks.createStartContract(); - const data = dataPluginMock.createStartContract(); - - const dslQuery = { - bool: { - must: [], - filter: [{ match_all: {} }], - should: [], - must_not: [], - }, - }; - - function getIndexPatternList() { - return [ - { - id: '1', - title: '1', - fields: [{ name: 'ip1_field_1' }, { name: 'ip1_field_2' }], - hasRestrictions: false, - }, - { - id: '2', - title: '2', - fields: [{ name: 'ip2_field_1' }, { name: 'ip2_field_2' }], - hasRestrictions: false, - }, - { - id: '3', - title: '3', - fields: [{ name: 'ip3_field_1' }, { name: 'ip3_field_2' }], - hasRestrictions: false, - }, - ] as unknown as IndexPattern[]; - } - - beforeEach(() => { - core.uiSettings.get.mockImplementation((key: string) => { - if (key === UI_SETTINGS.META_FIELDS) { - return []; - } - }); - dataViews.get.mockImplementation((id: string) => - Promise.resolve( - getIndexPatternList().find( - (indexPattern) => indexPattern.id === id - ) as unknown as DataView - ) - ); - }); - - it('should call once for each index pattern', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation( - (dataView: DataViewSpec | DataView) => - Promise.resolve(dataView.fields) as Promise - ); - - await syncExistingFields({ - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: getIndexPatternList(), - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }); - - expect(dataViews.get).toHaveBeenCalledTimes(3); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(3); - expect(updateIndexPatterns).toHaveBeenCalledTimes(1); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState).toEqual({ - isFirstExistenceFetch: false, - existingFields: { - '1': { ip1_field_1: true, ip1_field_2: true }, - '2': { ip2_field_1: true, ip2_field_2: true }, - '3': { ip3_field_1: true, ip3_field_2: true }, - }, - }); - }); - - it('should call onNoData callback if current index pattern returns no fields', async () => { - const updateIndexPatterns = jest.fn(); - const onNoData = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation( - async (dataView: DataViewSpec | DataView) => { - return (dataView.title === '1' - ? [{ name: `${dataView.title}_field_1` }, { name: `${dataView.title}_field_2` }] - : []) as unknown as Promise; - } - ); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: getIndexPatternList(), - updateIndexPatterns, - dslQuery, - onNoData, - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - expect(onNoData).not.toHaveBeenCalled(); - - await syncExistingFields({ ...args, isFirstExistenceFetch: true }); - expect(onNoData).not.toHaveBeenCalled(); - }); - - it('should set all fields to available and existence error flag if the request fails', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation(() => { - return new Promise((_, reject) => { - reject(new Error()); - }); - }); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: [ - { - id: '1', - title: '1', - hasRestrictions: false, - fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], - }, - ] as IndexPattern[], - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState.existenceFetchFailed).toEqual(true); - expect(newState.existenceFetchTimeout).toEqual(false); - expect(newState.existingFields['1']).toEqual({ - field1: true, - field2: true, - }); - }); - - it('should set all fields to available and existence error flag if the request times out', async () => { - const updateIndexPatterns = jest.fn(); - dataViews.getFieldsForIndexPattern.mockImplementation(() => { - return new Promise((_, reject) => { - const error = createHttpFetchError( - 'timeout', - 'error', - {} as Request, - { status: 408 } as Response - ); - reject(error); - }); - }); - - const args = { - dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - indexPatternList: [ - { - id: '1', - title: '1', - hasRestrictions: false, - fields: [{ name: 'field1' }, { name: 'field2' }] as IndexPatternField[], - }, - ] as IndexPattern[], - updateIndexPatterns, - dslQuery, - onNoData: jest.fn(), - currentIndexPatternTitle: 'abc', - isFirstExistenceFetch: false, - existingFields: {}, - core, - data, - dataViews, - }; - - await syncExistingFields(args); - - const [newState, options] = updateIndexPatterns.mock.calls[0]; - expect(options).toEqual({ applyImmediately: true }); - - expect(newState.existenceFetchFailed).toEqual(false); - expect(newState.existenceFetchTimeout).toEqual(true); - expect(newState.existingFields['1']).toEqual({ - field1: true, - field2: true, - }); - }); - }); }); diff --git a/x-pack/plugins/lens/public/data_views_service/loader.ts b/x-pack/plugins/lens/public/data_views_service/loader.ts index f0184d0a11d0b..f33ba8f3d37a9 100644 --- a/x-pack/plugins/lens/public/data_views_service/loader.ts +++ b/x-pack/plugins/lens/public/data_views_service/loader.ts @@ -8,13 +8,8 @@ import { isNestedField } from '@kbn/data-views-plugin/common'; import type { DataViewsContract, DataView, DataViewSpec } from '@kbn/data-views-plugin/public'; import { keyBy } from 'lodash'; -import { CoreStart } from '@kbn/core/public'; -import { DataPublicPluginStart } from '@kbn/data-plugin/public'; -import { loadFieldExisting } from '@kbn/unified-field-list-plugin/public'; import { IndexPattern, IndexPatternField, IndexPatternMap, IndexPatternRef } from '../types'; import { documentField } from '../datasources/form_based/document_field'; -import { DateRange } from '../../common'; -import { DataViewsState } from '../state_management'; type ErrorHandler = (err: Error) => void; type MinimalDataViewsContract = Pick; @@ -247,120 +242,3 @@ export async function ensureIndexPattern({ }; return newIndexPatterns; } - -async function refreshExistingFields({ - dateRange, - indexPatternList, - dslQuery, - core, - data, - dataViews, -}: { - dateRange: DateRange; - indexPatternList: IndexPattern[]; - dslQuery: object; - core: Pick; - data: DataPublicPluginStart; - dataViews: DataViewsContract; -}) { - try { - const emptinessInfo = await Promise.all( - indexPatternList.map(async (pattern) => { - if (pattern.hasRestrictions) { - return { - indexPatternTitle: pattern.title, - existingFieldNames: pattern.fields.map((field) => field.name), - }; - } - - const dataView = await dataViews.get(pattern.id); - return await loadFieldExisting({ - dslQuery, - fromDate: dateRange.fromDate, - toDate: dateRange.toDate, - timeFieldName: pattern.timeFieldName, - data, - uiSettingsClient: core.uiSettings, - dataViewsService: dataViews, - dataView, - }); - }) - ); - return { result: emptinessInfo, status: 200 }; - } catch (e) { - return { result: undefined, status: e.res?.status as number }; - } -} - -type FieldsPropsFromDataViewsState = Pick< - DataViewsState, - 'existingFields' | 'isFirstExistenceFetch' | 'existenceFetchTimeout' | 'existenceFetchFailed' ->; -export async function syncExistingFields({ - updateIndexPatterns, - isFirstExistenceFetch, - currentIndexPatternTitle, - onNoData, - existingFields, - ...requestOptions -}: { - dateRange: DateRange; - indexPatternList: IndexPattern[]; - existingFields: Record>; - updateIndexPatterns: ( - newFieldState: FieldsPropsFromDataViewsState, - options: { applyImmediately: boolean } - ) => void; - isFirstExistenceFetch: boolean; - currentIndexPatternTitle: string; - dslQuery: object; - onNoData?: () => void; - core: Pick; - data: DataPublicPluginStart; - dataViews: DataViewsContract; -}) { - const { indexPatternList } = requestOptions; - const newExistingFields = { ...existingFields }; - - const { result, status } = await refreshExistingFields(requestOptions); - - if (result) { - if (isFirstExistenceFetch) { - const fieldsCurrentIndexPattern = result.find( - (info) => info.indexPatternTitle === currentIndexPatternTitle - ); - if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { - onNoData?.(); - } - } - - for (const { indexPatternTitle, existingFieldNames } of result) { - newExistingFields[indexPatternTitle] = booleanMap(existingFieldNames); - } - } else { - for (const { title, fields } of indexPatternList) { - newExistingFields[title] = booleanMap(fields.map((field) => field.name)); - } - } - - updateIndexPatterns( - { - existingFields: newExistingFields, - ...(result - ? { isFirstExistenceFetch: status !== 200 } - : { - isFirstExistenceFetch, - existenceFetchFailed: status !== 408, - existenceFetchTimeout: status === 408, - }), - }, - { applyImmediately: true } - ); -} - -function booleanMap(keys: string[]) { - return keys.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {} as Record); -} diff --git a/x-pack/plugins/lens/public/data_views_service/mocks.ts b/x-pack/plugins/lens/public/data_views_service/mocks.ts index ed8d6e86e58a4..b4acacbe98b73 100644 --- a/x-pack/plugins/lens/public/data_views_service/mocks.ts +++ b/x-pack/plugins/lens/public/data_views_service/mocks.ts @@ -12,7 +12,7 @@ import { createMockedRestrictedIndexPattern, } from '../datasources/form_based/mocks'; import { DataViewsState } from '../state_management'; -import { ExistingFieldsMap, IndexPattern } from '../types'; +import { IndexPattern } from '../types'; import { getFieldByNameFactory } from './loader'; /** @@ -22,25 +22,13 @@ import { getFieldByNameFactory } from './loader'; export const createMockDataViewsState = ({ indexPatterns, indexPatternRefs, - isFirstExistenceFetch, - existingFields, }: Partial = {}): DataViewsState => { const refs = indexPatternRefs ?? Object.values(indexPatterns ?? {}).map(({ id, title, name }) => ({ id, title, name })); - const allFields = - existingFields ?? - refs.reduce((acc, { id, title }) => { - if (indexPatterns && id in indexPatterns) { - acc[title] = Object.fromEntries(indexPatterns[id].fields.map((f) => [f.displayName, true])); - } - return acc; - }, {} as ExistingFieldsMap); return { indexPatterns: indexPatterns ?? {}, indexPatternRefs: refs, - isFirstExistenceFetch: Boolean(isFirstExistenceFetch), - existingFields: allFields, }; }; diff --git a/x-pack/plugins/lens/public/data_views_service/service.ts b/x-pack/plugins/lens/public/data_views_service/service.ts index 28a0d82799992..5192de1d2385e 100644 --- a/x-pack/plugins/lens/public/data_views_service/service.ts +++ b/x-pack/plugins/lens/public/data_views_service/service.ts @@ -14,14 +14,8 @@ import { UPDATE_FILTER_REFERENCES_ACTION, UPDATE_FILTER_REFERENCES_TRIGGER, } from '@kbn/unified-search-plugin/public'; -import type { DateRange } from '../../common'; import type { IndexPattern, IndexPatternMap, IndexPatternRef } from '../types'; -import { - ensureIndexPattern, - loadIndexPatternRefs, - loadIndexPatterns, - syncExistingFields, -} from './loader'; +import { ensureIndexPattern, loadIndexPatternRefs, loadIndexPatterns } from './loader'; import type { DataViewsState } from '../state_management'; import { generateId } from '../id_generator'; @@ -71,18 +65,6 @@ export interface IndexPatternServiceAPI { id: string; cache: IndexPatternMap; }) => Promise; - /** - * Loads the existingFields map given the current context - */ - refreshExistingFields: (args: { - dateRange: DateRange; - currentIndexPatternTitle: string; - dslQuery: object; - onNoData?: () => void; - existingFields: Record>; - indexPatternList: IndexPattern[]; - isFirstExistenceFetch: boolean; - }) => Promise; replaceDataViewId: (newDataView: DataView) => Promise; /** @@ -150,14 +132,6 @@ export function createIndexPatternService({ }, ensureIndexPattern: (args) => ensureIndexPattern({ onError: onChangeError, dataViews, ...args }), - refreshExistingFields: (args) => - syncExistingFields({ - updateIndexPatterns, - ...args, - data, - dataViews, - core, - }), loadIndexPatternRefs: async ({ isFullEditor }) => isFullEditor ? loadIndexPatternRefs(dataViews) : [], getDefaultIndex: () => core.uiSettings.get('defaultIndex'), diff --git a/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts b/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts index 47af8d816b73f..7ad4172ce3829 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/__mocks__/loader.ts @@ -28,11 +28,9 @@ export function loadInitialDataViews() { const restricted = createMockedRestrictedIndexPattern(); return { indexPatternRefs: [], - existingFields: {}, indexPatterns: { [indexPattern.id]: indexPattern, [restricted.id]: restricted, }, - isFirstExistenceFetch: false, }; } diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss b/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss index ef68c784100e4..32887d3f9350d 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.scss @@ -14,15 +14,6 @@ margin-bottom: $euiSizeS; } -.lnsInnerIndexPatternDataPanel__titleTooltip { - margin-right: $euiSizeXS; -} - -.lnsInnerIndexPatternDataPanel__fieldItems { - // Quick fix for making sure the shadow and focus rings are visible outside the accordion bounds - padding: $euiSizeXS; -} - .lnsInnerIndexPatternDataPanel__textField { @include euiFormControlLayoutPadding(1, 'right'); @include euiFormControlLayoutPadding(1, 'left'); @@ -60,4 +51,4 @@ .lnsFilterButton .euiFilterButton__textShift { min-width: 0; -} \ No newline at end of file +} diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx index e7b0cd6d457a9..6639484ca6be4 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.test.tsx @@ -13,15 +13,16 @@ import { dataViewPluginMocks, Start as DataViewPublicStart, } from '@kbn/data-views-plugin/public/mocks'; -import { InnerFormBasedDataPanel, FormBasedDataPanel, Props } from './datapanel'; -import { FieldList } from './field_list'; +import { InnerFormBasedDataPanel, FormBasedDataPanel } from './datapanel'; +import { FieldListGrouped } from '@kbn/unified-field-list-plugin/public'; +import * as UseExistingFieldsApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields'; +import * as ExistingFieldsServiceApi from '@kbn/unified-field-list-plugin/public/services/field_existing/load_field_existing'; import { FieldItem } from './field_item'; -import { NoFieldsCallout } from './no_fields_callout'; import { act } from 'react-dom/test-utils'; import { coreMock } from '@kbn/core/public/mocks'; import { FormBasedPrivateState } from './types'; -import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; -import { EuiProgress, EuiLoadingSpinner } from '@elastic/eui'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { EuiCallOut, EuiLoadingSpinner, EuiProgress } from '@elastic/eui'; import { documentField } from './document_field'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; @@ -33,10 +34,9 @@ import { DOCUMENT_FIELD_NAME } from '../../../common'; import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mock'; import { createMockFramePublicAPI } from '../../mocks'; import { DataViewsState } from '../../state_management'; -import { ExistingFieldsMap, FramePublicAPI, IndexPattern } from '../../types'; -import { IndexPatternServiceProps } from '../../data_views_service/service'; -import { FieldSpec, DataView } from '@kbn/data-views-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; import { UI_SETTINGS } from '@kbn/data-plugin/public'; +import { ReactWrapper } from 'enzyme'; const fieldsOne = [ { @@ -162,17 +162,12 @@ const fieldsThree = [ documentField, ]; -function getExistingFields(indexPatterns: Record) { - const existingFields: ExistingFieldsMap = {}; - for (const { title, fields } of Object.values(indexPatterns)) { - const fieldsMap: Record = {}; - for (const { displayName, name } of fields) { - fieldsMap[displayName ?? name] = true; - } - existingFields[title] = fieldsMap; - } - return existingFields; -} +jest.spyOn(UseExistingFieldsApi, 'useExistingFieldsFetcher'); +jest.spyOn(UseExistingFieldsApi, 'useExistingFieldsReader'); +jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting').mockImplementation(async () => ({ + indexPatternTitle: 'test', + existingFieldNames: [], +})); const initialState: FormBasedPrivateState = { currentIndexPatternId: '1', @@ -234,8 +229,63 @@ const initialState: FormBasedPrivateState = { }, }; -function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial = {}) { +function getFrameAPIMock({ + indexPatterns, + ...rest +}: Partial & { indexPatterns: DataViewsState['indexPatterns'] }) { const frameAPI = createMockFramePublicAPI(); + + return { + ...frameAPI, + dataViews: { + ...frameAPI.dataViews, + indexPatterns, + ...rest, + }, + }; +} + +const dslQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; + +// @ts-expect-error Portal mocks are notoriously difficult to type +ReactDOM.createPortal = jest.fn((element) => element); + +async function mountAndWaitForLazyModules(component: React.ReactElement): Promise { + let inst: ReactWrapper; + await act(async () => { + inst = await mountWithIntl(component); + // wait for lazy modules + await new Promise((resolve) => setTimeout(resolve, 0)); + await inst.update(); + }); + + return inst!; +} + +describe('FormBased Data Panel', () => { + const indexPatterns = { + a: { + id: 'a', + title: 'aaa', + timeFieldName: 'atime', + fields: fieldsOne, + getFieldByName: getFieldByNameFactory(fieldsOne), + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, + b: { + id: 'b', + title: 'bbb', + timeFieldName: 'btime', + fields: fieldsTwo, + getFieldByName: getFieldByNameFactory(fieldsTwo), + hasRestrictions: false, + isPersisted: true, + spec: {}, + }, + }; + const defaultIndexPatterns = { '1': { id: '1', @@ -268,42 +318,7 @@ function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial element); -describe('FormBased Data Panel', () => { - const indexPatterns = { - a: { - id: 'a', - title: 'aaa', - timeFieldName: 'atime', - fields: [{ name: 'aaa_field_1' }, { name: 'aaa_field_2' }], - getFieldByName: getFieldByNameFactory([]), - hasRestrictions: false, - }, - b: { - id: 'b', - title: 'bbb', - timeFieldName: 'btime', - fields: [{ name: 'bbb_field_1' }, { name: 'bbb_field_2' }], - getFieldByName: getFieldByNameFactory([]), - hasRestrictions: false, - }, - }; let defaultProps: Parameters[0] & { showNoDataPopover: () => void; }; @@ -313,9 +328,10 @@ describe('FormBased Data Panel', () => { beforeEach(() => { core = coreMock.createStart(); dataViews = dataViewPluginMocks.createStartContract(); + const frame = getFrameAPIMock({ indexPatterns: defaultIndexPatterns }); defaultProps = { data: dataPluginMock.createStartContract(), - dataViews: dataViewPluginMocks.createStartContract(), + dataViews, fieldFormats: fieldFormatsServiceMock.createStartContract(), indexPatternFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(), onIndexPatternRefresh: jest.fn(), @@ -334,12 +350,34 @@ describe('FormBased Data Panel', () => { hasSuggestionForField: jest.fn(() => false), uiActions: uiActionsPluginMock.createStartContract(), indexPatternService: createIndexPatternServiceMock({ core, dataViews }), - frame: getFrameAPIMock(), + frame, + activeIndexPatterns: [frame.dataViews.indexPatterns['1']], }; + + core.uiSettings.get.mockImplementation((key: string) => { + if (key === UI_SETTINGS.META_FIELDS) { + return []; + } + }); + dataViews.get.mockImplementation(async (id: string) => { + const dataView = [ + indexPatterns.a, + indexPatterns.b, + defaultIndexPatterns['1'], + defaultIndexPatterns['2'], + defaultIndexPatterns['3'], + ].find((indexPattern) => indexPattern.id === id) as unknown as DataView; + dataView.metaFields = ['_id']; + return dataView; + }); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear(); + (UseExistingFieldsApi.useExistingFieldsReader as jest.Mock).mockClear(); + (UseExistingFieldsApi.useExistingFieldsFetcher as jest.Mock).mockClear(); + UseExistingFieldsApi.resetExistingFieldsCache(); }); - it('should render a warning if there are no index patterns', () => { - const wrapper = shallowWithIntl( + it('should render a warning if there are no index patterns', async () => { + const wrapper = await mountAndWaitForLazyModules( { frame={createMockFramePublicAPI()} /> ); - expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="indexPattern-no-indexpatterns"]').exists()).toBeTruthy(); }); describe('loading existence data', () => { - function testProps(updateIndexPatterns: IndexPatternServiceProps['updateIndexPatterns']) { - core.uiSettings.get.mockImplementation((key: string) => { - if (key === UI_SETTINGS.META_FIELDS) { - return []; - } - }); - dataViews.getFieldsForIndexPattern.mockImplementation((dataView) => { - return Promise.resolve([ - { name: `${dataView.title}_field_1` }, - { name: `${dataView.title}_field_2` }, - ]) as Promise; - }); - dataViews.get.mockImplementation(async (id: string) => { - return [indexPatterns.a, indexPatterns.b].find( - (indexPattern) => indexPattern.id === id - ) as unknown as DataView; - }); + function testProps({ + currentIndexPatternId, + otherProps, + }: { + currentIndexPatternId: keyof typeof indexPatterns; + otherProps?: object; + }) { return { ...defaultProps, indexPatternService: createIndexPatternServiceMock({ - updateIndexPatterns, + updateIndexPatterns: jest.fn(), core, dataViews, }), @@ -388,290 +416,329 @@ describe('FormBased Data Panel', () => { dragging: { id: '1', humanData: { label: 'Label' } }, }, dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, - frame: { - dataViews: { - indexPatternRefs: [], - existingFields: {}, - isFirstExistenceFetch: false, - indexPatterns, - }, - } as unknown as FramePublicAPI, + frame: getFrameAPIMock({ + indexPatterns: indexPatterns as unknown as DataViewsState['indexPatterns'], + }), state: { - currentIndexPatternId: 'a', + currentIndexPatternId, layers: { 1: { - indexPatternId: 'a', + indexPatternId: currentIndexPatternId, columnOrder: [], columns: {}, }, }, } as FormBasedPrivateState, + ...(otherProps || {}), }; } - async function testExistenceLoading( - props: Props, - stateChanges?: Partial, - propChanges?: Partial - ) { - const inst = mountWithIntl(); + it('loads existence data', async () => { + const props = testProps({ + currentIndexPatternId: 'a', + }); - await act(async () => { - inst.update(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; }); - if (stateChanges || propChanges) { - await act(async () => { - inst.setProps({ - ...props, - ...(propChanges || {}), - state: { - ...props.state, - ...(stateChanges || {}), - }, - }); - inst.update(); - }); - } - } + const inst = await mountAndWaitForLazyModules(); - it('loads existence data', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns)); - - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + query: props.query, + filters: props.filters, + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(UseExistingFieldsApi.useExistingFieldsReader).toHaveBeenCalled(); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' ); }); it('loads existence data for current index pattern id', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), { + const props = testProps({ currentIndexPatternId: 'b', }); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.b.fields[0].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.b], + query: props.query, + filters: props.filters, + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(UseExistingFieldsApi.useExistingFieldsReader).toHaveBeenCalled(); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' ); }); it('does not load existence data if date and index pattern ids are unchanged', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading( - testProps(updateIndexPatterns), - { - currentIndexPatternId: 'a', + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, }, - { dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' } } + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); - expect(updateIndexPatterns).toHaveBeenCalledTimes(1); + await act(async () => { + await inst.setProps({ dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' } }); + await inst.update(); + }); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); }); it('loads existence data if date range changes', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), undefined, { - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' }, + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, + }, }); - expect(updateIndexPatterns).toHaveBeenCalledTimes(2); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(2); - expect(dataViews.get).toHaveBeenCalledTimes(2); - - const firstCall = dataViews.getFieldsForIndexPattern.mock.calls[0]; - expect(firstCall[0]).toEqual(indexPatterns.a); - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; }); - const secondCall = dataViews.getFieldsForIndexPattern.mock.calls[1]; - expect(secondCall[0]).toEqual(indexPatterns.a); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-02', - }, - }, + const inst = await mountAndWaitForLazyModules(); + + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) + ); + + await act(async () => { + await inst.setProps({ dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' } }); + await inst.update(); }); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-02', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) ); }); it('loads existence data if layer index pattern changes', async () => { - const updateIndexPatterns = jest.fn(); - await testExistenceLoading(testProps(updateIndexPatterns), { - layers: { - 1: { - indexPatternId: 'b', - columnOrder: [], - columns: {}, - }, + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + dateRange: { fromDate: '2019-01-01', toDate: '2020-01-01' }, }, }); - expect(updateIndexPatterns).toHaveBeenCalledTimes(2); - - const secondCall = dataViews.getFieldsForIndexPattern.mock.calls[1]; - expect(secondCall[0]).toEqual(indexPatterns.a); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(secondCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - atime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, - }); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation( + async ({ dataView }) => { + return { + existingFieldNames: + dataView === indexPatterns.a + ? [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name] + : [indexPatterns.b.fields[0].name], + }; + } + ); - const thirdCall = dataViews.getFieldsForIndexPattern.mock.calls[2]; - expect(thirdCall[0]).toEqual(indexPatterns.b); - expect(thirdCall[1]?.filter?.bool?.filter).toContainEqual(dslQuery); - expect(thirdCall[1]?.filter?.bool?.filter).toContainEqual({ - range: { - btime: { - format: 'strict_date_optional_time', - gte: '2019-01-01', - lte: '2020-01-01', - }, - }, - }); + const inst = await mountAndWaitForLazyModules(); - expect(updateIndexPatterns).toHaveBeenCalledWith( - { - existingFields: { - aaa: { - aaa_field_1: true, - aaa_field_2: true, - }, - bbb: { - bbb_field_1: true, - bbb_field_2: true, - }, - }, - isFirstExistenceFetch: false, - }, - { applyImmediately: true } + expect(UseExistingFieldsApi.useExistingFieldsFetcher).toHaveBeenCalledWith( + expect.objectContaining({ + dataViews: [indexPatterns.a], + fromDate: props.dateRange.fromDate, + toDate: props.dateRange.toDate, + }) + ); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.a, + timeFieldName: indexPatterns.a.timeFieldName, + }) ); - }); - it('shows a loading indicator when loading', async () => { - const updateIndexPatterns = jest.fn(); - const load = async () => {}; - const inst = mountWithIntl(); - expect(inst.find(EuiProgress).length).toEqual(1); - await act(load); - inst.update(); - expect(inst.find(EuiProgress).length).toEqual(0); - }); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' + ); - it('does not perform multiple queries at once', async () => { - const updateIndexPatterns = jest.fn(); - let queryCount = 0; - let overlapCount = 0; - const props = testProps(updateIndexPatterns); + await act(async () => { + await inst.setProps({ + currentIndexPatternId: 'b', + state: { + currentIndexPatternId: 'b', + layers: { + 1: { + indexPatternId: 'b', + columnOrder: [], + columns: {}, + }, + }, + } as FormBasedPrivateState, + }); + await inst.update(); + }); - dataViews.getFieldsForIndexPattern.mockImplementation((dataView) => { - if (queryCount) { - ++overlapCount; - } - ++queryCount; - const result = Promise.resolve([ - { name: `${dataView.title}_field_1` }, - { name: `${dataView.title}_field_2` }, - ]) as Promise; + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(2); + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + fromDate: '2019-01-01', + toDate: '2020-01-01', + dslQuery, + dataView: indexPatterns.b, + timeFieldName: indexPatterns.b.timeFieldName, + }) + ); - result.then(() => --queryCount); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' + ); + }); - return result; + it('shows a loading indicator when loading', async () => { + const props = testProps({ + currentIndexPatternId: 'b', }); - const inst = mountWithIntl(); + let resolveFunction: (arg: unknown) => void; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + const inst = await mountAndWaitForLazyModules(); - inst.update(); + expect(inst.find(EuiProgress).length).toEqual(1); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '' + ); - act(() => { - (inst.setProps as unknown as (props: unknown) => {})({ - ...props, - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-02' }, + await act(async () => { + resolveFunction!({ + existingFieldNames: [indexPatterns.b.fields[0].name], }); - inst.update(); + await inst.update(); }); await act(async () => { - (inst.setProps as unknown as (props: unknown) => {})({ - ...props, - dateRange: { fromDate: '2019-01-01', toDate: '2020-01-03' }, - }); - inst.update(); + await inst.update(); }); - expect(dataViews.getFieldsForIndexPattern).toHaveBeenCalledTimes(2); - expect(overlapCount).toEqual(0); + expect(inst.find(EuiProgress).length).toEqual(0); + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '1 available field. 2 empty fields. 0 meta fields.' + ); }); - it("should default to empty dsl if query can't be parsed", async () => { - const updateIndexPatterns = jest.fn(); - const props = { - ...testProps(updateIndexPatterns), - query: { - language: 'kuery', - query: '@timestamp : NOT *', - }, - }; - await testExistenceLoading(props, undefined, undefined); + it("should trigger showNoDataPopover if fields don't have data", async () => { + const props = testProps({ + currentIndexPatternId: 'a', + }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [], + }; + }); + + const inst = await mountAndWaitForLazyModules(); - const firstCall = dataViews.getFieldsForIndexPattern.mock.calls[0]; - expect(firstCall[1]?.filter?.bool?.filter).toContainEqual({ - bool: { - must_not: { - match_all: {}, + expect(defaultProps.showNoDataPopover).toHaveBeenCalled(); + + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '0 available fields. 5 empty fields. 0 meta fields.' + ); + }); + + it("should default to empty dsl if query can't be parsed", async () => { + const props = testProps({ + currentIndexPatternId: 'a', + otherProps: { + query: { + language: 'kuery', + query: '@timestamp : NOT *', }, }, }); + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [indexPatterns.a.fields[0].name, indexPatterns.a.fields[1].name], + }; + }); + + const inst = await mountAndWaitForLazyModules(); + + expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledWith( + expect.objectContaining({ + dslQuery: { + bool: { + must_not: { + match_all: {}, + }, + }, + }, + }) + ); + + expect(inst.find('[data-test-subj="lnsIndexPattern__ariaDescription"]').first().text()).toBe( + '2 available fields. 3 empty fields. 0 meta fields.' + ); }); }); @@ -680,15 +747,13 @@ describe('FormBased Data Panel', () => { beforeEach(() => { props = { ...defaultProps, - frame: getFrameAPIMock({ - existingFields: { - idx1: { - bytes: true, - memory: true, - }, - }, - }), }; + + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: ['bytes', 'memory'], + }; + }); }); it('should list all selected fields if exist', async () => { @@ -696,7 +761,9 @@ describe('FormBased Data Panel', () => { ...props, layerFields: ['bytes'], }; - const wrapper = mountWithIntl(); + + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper .find('[data-test-subj="lnsIndexPatternSelectedFields"]') @@ -706,9 +773,10 @@ describe('FormBased Data Panel', () => { }); it('should not list the selected fields accordion if no fields given', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect( - wrapper + wrapper! .find('[data-test-subj="lnsIndexPatternSelectedFields"]') .find(FieldItem) .map((fieldItem) => fieldItem.prop('field').name) @@ -716,14 +784,14 @@ describe('FormBased Data Panel', () => { }); it('should list all supported fields in the pattern sorted alphabetically in groups', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect(wrapper.find(FieldItem).first().prop('field').displayName).toEqual('Records'); + const availableAccordion = wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]'); expect( - wrapper - .find('[data-test-subj="lnsIndexPatternAvailableFields"]') - .find(FieldItem) - .map((fieldItem) => fieldItem.prop('field').name) + availableAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name) ).toEqual(['memory', 'bytes']); + expect(availableAccordion.find(FieldItem).at(0).prop('exists')).toEqual(true); wrapper .find('[data-test-subj="lnsIndexPatternEmptyFields"]') .find('button') @@ -736,10 +804,11 @@ describe('FormBased Data Panel', () => { expect( emptyAccordion.find(FieldItem).map((fieldItem) => fieldItem.prop('field').displayName) ).toEqual(['client', 'source', 'timestampLabel']); + expect(emptyAccordion.find(FieldItem).at(1).prop('exists')).toEqual(false); }); it('should show meta fields accordion', async () => { - const wrapper = mountWithIntl( + const wrapper = await mountAndWaitForLazyModules( { })} /> ); + wrapper .find('[data-test-subj="lnsIndexPatternMetaFields"]') .find('button') @@ -777,13 +847,15 @@ describe('FormBased Data Panel', () => { }); it('should display NoFieldsCallout when all fields are empty', async () => { - const wrapper = mountWithIntl( - - ); - expect(wrapper.find(NoFieldsCallout).length).toEqual(2); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + return { + existingFieldNames: [], + }; + }); + + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find(EuiCallOut).length).toEqual(2); expect( wrapper .find('[data-test-subj="lnsIndexPatternAvailableFields"]') @@ -804,52 +876,55 @@ describe('FormBased Data Panel', () => { }); it('should display spinner for available fields accordion if existing fields are not loaded yet', async () => { - const wrapper = mountWithIntl( - - ); + let resolveFunction: (arg: unknown) => void; + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset(); + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => { + return new Promise((resolve) => { + resolveFunction = resolve; + }); + }); + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]').find(EuiLoadingSpinner) .length ).toEqual(1); - wrapper.setProps({ frame: getFrameAPIMock({ existingFields: { idx1: {} } }) }); - expect(wrapper.find(NoFieldsCallout).length).toEqual(2); - }); + expect(wrapper.find(EuiCallOut).length).toEqual(0); - it('should not allow field details when error', () => { - const wrapper = mountWithIntl( - - ); + await act(async () => { + resolveFunction!({ + existingFieldNames: [], + }); + }); - expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( - expect.objectContaining({ - AvailableFields: expect.objectContaining({ hideDetails: true }), - }) - ); + await act(async () => { + await wrapper.update(); + }); + + expect( + wrapper.find('[data-test-subj="lnsIndexPatternAvailableFields"]').find(EuiLoadingSpinner) + .length + ).toEqual(0); + expect(wrapper.find(EuiCallOut).length).toEqual(2); }); - it('should allow field details when timeout', () => { - const wrapper = mountWithIntl( - - ); + it('should not allow field details when error', async () => { + (ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => { + throw new Error('test'); + }); - expect(wrapper.find(FieldList).prop('fieldGroups')).toEqual( + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find(FieldListGrouped).prop('fieldGroups')).toEqual( expect.objectContaining({ - AvailableFields: expect.objectContaining({ hideDetails: false }), + AvailableFields: expect.objectContaining({ hideDetails: true }), }) ); }); - it('should filter down by name', () => { - const wrapper = mountWithIntl(); + it('should filter down by name', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, @@ -867,8 +942,9 @@ describe('FormBased Data Panel', () => { ]); }); - it('should announce filter in live region', () => { - const wrapper = mountWithIntl(); + it('should announce filter in live region', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, @@ -886,8 +962,8 @@ describe('FormBased Data Panel', () => { ); }); - it('should filter down by type', () => { - const wrapper = mountWithIntl(); + it('should filter down by type', async () => { + const wrapper = await mountAndWaitForLazyModules(); wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); @@ -898,8 +974,8 @@ describe('FormBased Data Panel', () => { ).toEqual(['amemory', 'bytes']); }); - it('should display no fields in groups when filtered by type Record', () => { - const wrapper = mountWithIntl(); + it('should display no fields in groups when filtered by type Record', async () => { + const wrapper = await mountAndWaitForLazyModules(); wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); @@ -908,11 +984,12 @@ describe('FormBased Data Panel', () => { expect(wrapper.find(FieldItem).map((fieldItem) => fieldItem.prop('field').name)).toEqual([ DOCUMENT_FIELD_NAME, ]); - expect(wrapper.find(NoFieldsCallout).length).toEqual(3); + expect(wrapper.find(EuiCallOut).length).toEqual(3); }); - it('should toggle type if clicked again', () => { - const wrapper = mountWithIntl(); + it('should toggle type if clicked again', async () => { + const wrapper = await mountAndWaitForLazyModules(); + wrapper.find('[data-test-subj="lnsIndexPatternFiltersToggle"]').first().simulate('click'); wrapper.find('[data-test-subj="typeFilter-number"]').first().simulate('click'); @@ -927,8 +1004,9 @@ describe('FormBased Data Panel', () => { ).toEqual(['Records', 'amemory', 'bytes', 'client', 'source', 'timestampLabel']); }); - it('should filter down by type and by name', () => { - const wrapper = mountWithIntl(); + it('should filter down by type and by name', async () => { + const wrapper = await mountAndWaitForLazyModules(); + act(() => { wrapper.find('[data-test-subj="lnsIndexPatternFieldSearch"]').simulate('change', { target: { value: 'me' }, diff --git a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx index 8a7916a01a09a..63fe3336c34d6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/datapanel.tsx @@ -6,32 +6,38 @@ */ import './datapanel.scss'; -import { uniq, groupBy } from 'lodash'; -import React, { useState, memo, useCallback, useMemo, useRef, useEffect } from 'react'; +import { uniq } from 'lodash'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + EuiCallOut, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFilterButton, EuiFlexGroup, EuiFlexItem, - EuiContextMenuPanel, - EuiContextMenuItem, - EuiPopover, - EuiCallOut, EuiFormControlLayout, - EuiFilterButton, - EuiScreenReaderOnly, EuiIcon, + EuiPopover, + EuiProgress, + htmlIdGenerator, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import type { EsQueryConfig, Query, Filter } from '@kbn/es-query'; import { FormattedMessage } from '@kbn/i18n-react'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { type DataView } from '@kbn/data-plugin/common'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; -import { htmlIdGenerator } from '@elastic/eui'; -import { buildEsQuery } from '@kbn/es-query'; -import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public'; import { VISUALIZE_GEO_FIELD_TRIGGER } from '@kbn/ui-actions-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; +import { + FieldsGroupNames, + FieldListGrouped, + type FieldListGroupedProps, + useExistingFieldsFetcher, + useGroupedFields, + useExistingFieldsReader, +} from '@kbn/unified-field-list-plugin/public'; import { ChartsPluginSetup } from '@kbn/charts-plugin/public'; import type { DatasourceDataPanelProps, @@ -42,12 +48,11 @@ import type { } from '../../types'; import { ChildDragDropProvider, DragContextState } from '../../drag_drop'; import type { FormBasedPrivateState } from './types'; -import { Loader } from '../../loader'; import { LensFieldIcon } from '../../shared_components/field_picker/lens_field_icon'; import { getFieldType } from './pure_utils'; -import { FieldGroups, FieldList } from './field_list'; -import { fieldContainsData, fieldExists } from '../../shared_components'; +import { fieldContainsData } from '../../shared_components'; import { IndexPatternServiceAPI } from '../../data_views_service/service'; +import { FieldItem } from './field_item'; export type Props = Omit< DatasourceDataPanelProps, @@ -65,10 +70,6 @@ export type Props = Omit< layerFields?: string[]; }; -function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { - return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); -} - const supportedFieldTypes = new Set([ 'string', 'number', @@ -104,25 +105,8 @@ const fieldTypeNames: Record = { murmur3: i18n.translate('xpack.lens.datatypes.murmur3', { defaultMessage: 'murmur3' }), }; -// Wrapper around buildEsQuery, handling errors (e.g. because a query can't be parsed) by -// returning a query dsl object not matching anything -function buildSafeEsQuery( - indexPattern: IndexPattern, - query: Query, - filters: Filter[], - queryConfig: EsQueryConfig -) { - try { - return buildEsQuery(indexPattern, query, filters, queryConfig); - } catch (e) { - return { - bool: { - must_not: { - match_all: {}, - }, - }, - }; - } +function onSupportedFieldFilter(field: IndexPatternField): boolean { + return supportedFieldTypes.has(field.type); } export function FormBasedDataPanel({ @@ -147,51 +131,22 @@ export function FormBasedDataPanel({ usedIndexPatterns, layerFields, }: Props) { - const { indexPatterns, indexPatternRefs, existingFields, isFirstExistenceFetch } = - frame.dataViews; + const { indexPatterns, indexPatternRefs } = frame.dataViews; const { currentIndexPatternId } = state; - const indexPatternList = uniq( - ( - usedIndexPatterns ?? Object.values(state.layers).map(({ indexPatternId }) => indexPatternId) - ).concat(currentIndexPatternId) - ) - .filter((id) => !!indexPatterns[id]) - .sort() - .map((id) => indexPatterns[id]); - - const dslQuery = buildSafeEsQuery( - indexPatterns[currentIndexPatternId], - query, - filters, - getEsQueryConfig(core.uiSettings) - ); + const activeIndexPatterns = useMemo(() => { + return uniq( + ( + usedIndexPatterns ?? Object.values(state.layers).map(({ indexPatternId }) => indexPatternId) + ).concat(currentIndexPatternId) + ) + .filter((id) => !!indexPatterns[id]) + .sort() + .map((id) => indexPatterns[id]); + }, [usedIndexPatterns, indexPatterns, state.layers, currentIndexPatternId]); return ( <> - - indexPatternService.refreshExistingFields({ - dateRange, - currentIndexPatternTitle: indexPatterns[currentIndexPatternId]?.title || '', - onNoData: showNoDataPopover, - dslQuery, - indexPatternList, - isFirstExistenceFetch, - existingFields, - }) - } - loadDeps={[ - query, - filters, - dateRange.fromDate, - dateRange.toDate, - indexPatternList.map((x) => `${x.title}:${x.timeFieldName}`).join(','), - // important here to rerun the fields existence on indexPattern change (i.e. add new fields in place) - frame.dataViews.indexPatterns, - ]} - /> - {Object.keys(indexPatterns).length === 0 && indexPatternRefs.length === 0 ? ( )} @@ -252,18 +209,6 @@ interface DataPanelState { isMetaAccordionOpen: boolean; } -const defaultFieldGroups: { - specialFields: IndexPatternField[]; - availableFields: IndexPatternField[]; - emptyFields: IndexPatternField[]; - metaFields: IndexPatternField[]; -} = { - specialFields: [], - availableFields: [], - emptyFields: [], - metaFields: [], -}; - const htmlId = htmlIdGenerator('datapanel'); const fieldSearchDescriptionId = htmlId(); @@ -286,9 +231,11 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ frame, onIndexPatternRefresh, layerFields, + showNoDataPopover, + activeIndexPatterns, }: Omit< DatasourceDataPanelProps, - 'state' | 'setState' | 'showNoDataPopover' | 'core' | 'onChangeIndexPattern' | 'usedIndexPatterns' + 'state' | 'setState' | 'core' | 'onChangeIndexPattern' | 'usedIndexPatterns' > & { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; @@ -301,6 +248,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor: IndexPatternFieldEditorStart; onIndexPatternRefresh: () => void; layerFields?: string[]; + activeIndexPatterns: IndexPattern[]; }) { const [localState, setLocalState] = useState({ nameFilter: '', @@ -310,10 +258,30 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ isEmptyAccordionOpen: false, isMetaAccordionOpen: false, }); - const { existenceFetchFailed, existenceFetchTimeout, indexPatterns, existingFields } = - frame.dataViews; + const { indexPatterns } = frame.dataViews; const currentIndexPattern = indexPatterns[currentIndexPatternId]; - const existingFieldsForIndexPattern = existingFields[currentIndexPattern?.title]; + + const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({ + dataViews: activeIndexPatterns as unknown as DataView[], + query, + filters, + fromDate: dateRange.fromDate, + toDate: dateRange.toDate, + services: { + data, + dataViews, + core, + }, + onNoData: (dataViewId) => { + if (dataViewId === currentIndexPatternId) { + showNoDataPopover(); + } + }, + }); + const fieldsExistenceReader = useExistingFieldsReader(); + const fieldsExistenceStatus = + fieldsExistenceReader.getFieldsExistenceStatus(currentIndexPatternId); + const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER); const allFields = useMemo(() => { if (!currentIndexPattern) return []; @@ -331,187 +299,68 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ ...localState.typeFilter, ]); - const fieldInfoUnavailable = - existenceFetchFailed || existenceFetchTimeout || currentIndexPattern?.hasRestrictions; - const editPermission = indexPatternFieldEditor.userPermissions.editIndexPattern() || !currentIndexPattern.isPersisted; - const unfilteredFieldGroups: FieldGroups = useMemo(() => { - const containsData = (field: IndexPatternField) => { - const overallField = currentIndexPattern?.getFieldByName(field.name); - return ( - overallField && - existingFieldsForIndexPattern && - fieldExists(existingFieldsForIndexPattern, overallField.name) - ); - }; - - const allSupportedTypesFields = allFields.filter((field) => - supportedFieldTypes.has(field.type) - ); - const usedByLayersFields = allFields.filter((field) => layerFields?.includes(field.name)); - const sorted = allSupportedTypesFields.sort(sortFields); - const groupedFields = { - ...defaultFieldGroups, - ...groupBy(sorted, (field) => { - if (field.type === 'document') { - return 'specialFields'; - } else if (field.meta) { - return 'metaFields'; - } else if (containsData(field)) { - return 'availableFields'; - } else return 'emptyFields'; - }), - }; - - const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling'); - - const fieldGroupDefinitions: FieldGroups = { - SpecialFields: { - fields: groupedFields.specialFields, - fieldCount: 1, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: false, - title: '', - hideDetails: true, - }, - SelectedFields: { - fields: usedByLayersFields, - fieldCount: usedByLayersFields.length, - isInitiallyOpen: true, - showInAccordion: true, - title: i18n.translate('xpack.lens.indexPattern.selectedFieldsLabel', { - defaultMessage: 'Selected fields', - }), - isAffectedByGlobalFilter: !!filters.length, - isAffectedByTimeFilter: true, - hideDetails: false, - hideIfEmpty: true, - }, - AvailableFields: { - fields: groupedFields.availableFields, - fieldCount: groupedFields.availableFields.length, - isInitiallyOpen: true, - showInAccordion: true, - title: fieldInfoUnavailable - ? i18n.translate('xpack.lens.indexPattern.allFieldsLabel', { - defaultMessage: 'All fields', - }) - : i18n.translate('xpack.lens.indexPattern.availableFieldsLabel', { - defaultMessage: 'Available fields', - }), - helpText: isUsingSampling - ? i18n.translate('xpack.lens.indexPattern.allFieldsSamplingLabelHelp', { - defaultMessage: - 'Available fields contain the data in the first 500 documents that match your filters. To view all fields, expand Empty fields. You are unable to create visualizations with full text, geographic, flattened, and object fields.', - }) - : i18n.translate('xpack.lens.indexPattern.allFieldsLabelHelp', { - 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: !!filters.length, - isAffectedByTimeFilter: true, - // Show details on timeout but not failure - hideDetails: fieldInfoUnavailable && !existenceFetchTimeout, - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noAvailableDataLabel', { - defaultMessage: `There are no available fields that contain data.`, - }), - }, - EmptyFields: { - fields: groupedFields.emptyFields, - fieldCount: groupedFields.emptyFields.length, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: true, - hideDetails: false, - title: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabel', { - defaultMessage: 'Empty fields', - }), - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noEmptyDataLabel', { - defaultMessage: `There are no empty fields.`, - }), - helpText: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabelHelp', { - defaultMessage: - 'Empty fields did not contain any values in the first 500 documents based on your filters.', - }), - }, - MetaFields: { - fields: groupedFields.metaFields, - fieldCount: groupedFields.metaFields.length, - isAffectedByGlobalFilter: false, - isAffectedByTimeFilter: false, - isInitiallyOpen: false, - showInAccordion: true, - hideDetails: false, - title: i18n.translate('xpack.lens.indexPattern.metaFieldsLabel', { - defaultMessage: 'Meta fields', - }), - defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noMetaDataLabel', { - defaultMessage: `There are no meta fields.`, - }), - }, - }; - - // do not show empty field accordion if there is no existence information - if (fieldInfoUnavailable) { - delete fieldGroupDefinitions.EmptyFields; - } - - return fieldGroupDefinitions; - }, [ - allFields, - core.uiSettings, - fieldInfoUnavailable, - filters.length, - existenceFetchTimeout, - currentIndexPattern, - existingFieldsForIndexPattern, - layerFields, - ]); - - const fieldGroups: FieldGroups = useMemo(() => { - const filterFieldGroup = (fieldGroup: IndexPatternField[]) => - fieldGroup.filter((field) => { - if ( - localState.nameFilter.length && - !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) && - !field.displayName.toLowerCase().includes(localState.nameFilter.toLowerCase()) - ) { - return false; - } - if (localState.typeFilter.length > 0) { - return localState.typeFilter.includes(getFieldType(field) as DataType); - } - return true; - }); - return Object.fromEntries( - Object.entries(unfilteredFieldGroups).map(([name, group]) => [ - name, - { ...group, fields: filterFieldGroup(group.fields) }, - ]) - ); - }, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]); - - const checkFieldExists = useCallback( - (field: IndexPatternField) => - fieldContainsData(field.name, currentIndexPattern, existingFieldsForIndexPattern), - [currentIndexPattern, existingFieldsForIndexPattern] + const onSelectedFieldFilter = useCallback( + (field: IndexPatternField): boolean => { + return Boolean(layerFields?.includes(field.name)); + }, + [layerFields] ); - const { nameFilter, typeFilter } = localState; + const onFilterField = useCallback( + (field: IndexPatternField) => { + if ( + localState.nameFilter.length && + !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) && + !field.displayName.toLowerCase().includes(localState.nameFilter.toLowerCase()) + ) { + return false; + } + if (localState.typeFilter.length > 0) { + return localState.typeFilter.includes(getFieldType(field) as DataType); + } + return true; + }, + [localState] + ); - const filter = useMemo( - () => ({ - nameFilter, - typeFilter, - }), - [nameFilter, typeFilter] + const onOverrideFieldGroupDetails = useCallback( + (groupName) => { + if (groupName === FieldsGroupNames.AvailableFields) { + const isUsingSampling = core.uiSettings.get('lens:useFieldExistenceSampling'); + + return { + helpText: isUsingSampling + ? i18n.translate('xpack.lens.indexPattern.allFieldsSamplingLabelHelp', { + defaultMessage: + 'Available fields contain the data in the first 500 documents that match your filters. To view all fields, expand Empty fields. You are unable to create visualizations with full text, geographic, flattened, and object fields.', + }) + : i18n.translate('xpack.lens.indexPattern.allFieldsLabelHelp', { + 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.', + }), + }; + } + }, + [core.uiSettings] ); + const { fieldGroups } = useGroupedFields({ + dataViewId: currentIndexPatternId, + allFields, + services: { + dataViews, + }, + fieldsExistenceReader, + isAffectedByGlobalFilter: Boolean(filters.length), + onFilterField, + onSupportedFieldFilter, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + }); + const closeFieldEditor = useRef<() => void | undefined>(); useEffect(() => { @@ -560,6 +409,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ onSave: () => { if (indexPatternInstance.isPersisted()) { refreshFieldList(); + refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); } @@ -574,6 +424,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor, refreshFieldList, indexPatternService, + refetchFieldsExistenceInfo, ] ); @@ -590,6 +441,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ onDelete: () => { if (indexPatternInstance.isPersisted()) { refreshFieldList(); + refetchFieldsExistenceInfo(indexPatternInstance.id); } else { indexPatternService.replaceDataViewId(indexPatternInstance); } @@ -604,24 +456,39 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ indexPatternFieldEditor, indexPatternService, refreshFieldList, + refetchFieldsExistenceInfo, ] ); - const fieldProps = useMemo( - () => ({ - core, - data, - fieldFormats, - indexPattern: currentIndexPattern, - highlight: localState.nameFilter.toLowerCase(), - dateRange, - query, - filters, - chartsThemeService: charts.theme, - }), + const renderFieldItem: FieldListGroupedProps['renderFieldItem'] = useCallback( + ({ field, itemIndex, groupIndex, hideDetails }) => ( + + ), [ core, - data, fieldFormats, currentIndexPattern, dateRange, @@ -629,6 +496,12 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ filters, localState.nameFilter, charts.theme, + fieldsExistenceReader.hasFieldData, + dropOntoWorkspace, + hasSuggestionForField, + editField, + removeField, + uiActions, ] ); @@ -640,6 +513,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({ direction="column" responsive={false} > + {isProcessing && } - -
      - {i18n.translate('xpack.lens.indexPatterns.fieldSearchLiveRegion', { - defaultMessage: - '{availableFields} available {availableFields, plural, one {field} other {fields}}. {emptyFields} empty {emptyFields, plural, one {field} other {fields}}. {metaFields} meta {metaFields, plural, one {field} other {fields}}.', - values: { - availableFields: fieldGroups.AvailableFields.fields.length, - // empty fields can be undefined if there is no existence information to be fetched - emptyFields: fieldGroups.EmptyFields?.fields.length || 0, - metaFields: fieldGroups.MetaFields.fields.length, - }, - })} -
      -
      - fieldGroups={fieldGroups} - hasSyncedExistingFields={!!existingFieldsForIndexPattern} - filter={filter} - currentIndexPatternId={currentIndexPatternId} - existenceFetchFailed={existenceFetchFailed} - existenceFetchTimeout={existenceFetchTimeout} - existFieldsInIndex={!!allFields.length} - dropOntoWorkspace={dropOntoWorkspace} - hasSuggestionForField={hasSuggestionForField} - editField={editField} - removeField={removeField} - uiActions={uiActions} + fieldsExistenceStatus={fieldsExistenceStatus} + fieldsExistInIndex={!!allFields.length} + renderFieldItem={renderFieldItem} + screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId} + data-test-subj="lnsIndexPattern" />
      diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx index 330d3285b2951..97dabaca05c03 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/dimension_editor.tsx @@ -606,7 +606,6 @@ export function DimensionEditor(props: DimensionEditorProps) { setIsCloseable, paramEditorCustomProps, ReferenceEditor, - existingFields: props.existingFields, ...services, }; @@ -789,7 +788,6 @@ export function DimensionEditor(props: DimensionEditorProps) { }} validation={validation} currentIndexPattern={currentIndexPattern} - existingFields={props.existingFields} selectionStyle={selectedOperationDefinition.selectionStyle} dateRange={dateRange} labelAppend={selectedOperationDefinition?.getHelpMessage?.({ @@ -815,7 +813,6 @@ export function DimensionEditor(props: DimensionEditorProps) { selectedColumn={selectedColumn as FieldBasedIndexPatternColumn} columnId={columnId} indexPattern={currentIndexPattern} - existingFields={props.existingFields} operationSupportMatrix={operationSupportMatrix} updateLayer={(newLayer) => { if (temporaryQuickFunction) { @@ -845,7 +842,6 @@ export function DimensionEditor(props: DimensionEditorProps) { const customParamEditor = ParamEditor ? ( <> { }; }); +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + const fields = [ { name: 'timestamp', @@ -197,14 +208,6 @@ describe('FormBasedDimensionEditor', () => { defaultProps = { indexPatterns: expectedIndexPatterns, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, state, setState, dateRange: { fromDate: 'now-1d', toDate: 'now' }, @@ -339,16 +342,15 @@ describe('FormBasedDimensionEditor', () => { }); it('should hide fields that have no data', () => { - const props = { - ...defaultProps, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - source: true, + (useExistingFieldsReader as jest.Mock).mockImplementationOnce(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'source'].includes(fieldName); }, - }, - }; - wrapper = mount(); + }; + }); + + wrapper = mount(); const options = wrapper .find(EuiComboBox) diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx index 877dc18156cdf..a135b08082c9e 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.test.tsx @@ -112,11 +112,7 @@ function getLayer(col1: GenericIndexPatternColumn = getStringBasedOperationColum }, }; } -function getDefaultOperationSupportMatrix( - layer: FormBasedLayer, - columnId: string, - existingFields: Record> -) { +function getDefaultOperationSupportMatrix(layer: FormBasedLayer, columnId: string) { return getOperationSupportMatrix({ state: { layers: { layer1: layer }, @@ -130,29 +126,36 @@ function getDefaultOperationSupportMatrix( }); } -function getExistingFields() { - const fields: Record = {}; - for (const field of defaultProps.indexPattern.fields) { - fields[field.name] = true; - } - return { - [defaultProps.indexPattern.title]: fields, - }; -} +const mockedReader = { + hasFieldData: (dataViewId: string, fieldName: string) => { + if (defaultProps.indexPattern.id !== dataViewId) { + return false; + } + + const map: Record = {}; + for (const field of defaultProps.indexPattern.fields) { + map[field.name] = true; + } + + return map[fieldName]; + }, +}; + +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => mockedReader), +})); describe('FieldInput', () => { it('should render a field select box', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( ); @@ -163,15 +166,13 @@ describe('FieldInput', () => { it('should render an error message when incomplete operation is on', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { (_, col: ReferenceBasedIndexPatternColumn) => { const updateLayerSpy = jest.fn(); const layer = getLayer(col); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix( - layer, - 'col1', - existingFields - ); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -234,19 +229,13 @@ describe('FieldInput', () => { (_, col: ReferenceBasedIndexPatternColumn) => { const updateLayerSpy = jest.fn(); const layer = getLayer(col); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix( - layer, - 'col1', - existingFields - ); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { it('should render an error message for invalid fields', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -295,15 +282,13 @@ describe('FieldInput', () => { it('should render a help message when passed and no errors are found', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -320,15 +305,13 @@ describe('FieldInput', () => { it('should prioritize errors over help messages', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { it('should update the layer on field selection', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -372,15 +353,13 @@ describe('FieldInput', () => { it('should not trigger when the same selected field is selected again', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( @@ -398,15 +377,13 @@ describe('FieldInput', () => { it('should prioritize incomplete fields over selected column field to display', () => { const updateLayerSpy = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( { const updateLayerSpy = jest.fn(); const onDeleteColumn = jest.fn(); const layer = getLayer(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix(layer, 'col1'); const instance = mount( diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx index ec471b70de614..462cd0b546f22 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/field_input.tsx @@ -22,7 +22,6 @@ export function FieldInput({ selectedColumn, columnId, indexPattern, - existingFields, operationSupportMatrix, updateLayer, onDeleteColumn, @@ -62,7 +61,6 @@ export function FieldInput({ void; onDeleteColumn?: () => void; - existingFields: ExistingFieldsMap[string]; fieldIsInvalid: boolean; markAllFieldsCompatible?: boolean; 'data-test-subj'?: string; @@ -47,12 +47,12 @@ export function FieldSelect({ operationByField, onChoose, onDeleteColumn, - existingFields, fieldIsInvalid, markAllFieldsCompatible, ['data-test-subj']: dataTestSub, ...rest }: FieldSelectProps) { + const { hasFieldData } = useExistingFieldsReader(); const memoizedFieldOptions = useMemo(() => { const fields = Object.keys(operationByField).sort(); @@ -67,8 +67,8 @@ export function FieldSelect({ (field) => currentIndexPattern.getFieldByName(field)?.type === 'document' ); - function containsData(field: string) { - return fieldContainsData(field, currentIndexPattern, existingFields); + function containsData(fieldName: string) { + return fieldContainsData(fieldName, currentIndexPattern, hasFieldData); } function fieldNamesToOptions(items: string[]) { @@ -145,7 +145,7 @@ export function FieldSelect({ selectedOperationType, currentIndexPattern, operationByField, - existingFields, + hasFieldData, markAllFieldsCompatible, ]); diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx index d46dabf6c12f3..cb50049e3fbec 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.test.tsx @@ -28,6 +28,16 @@ import { import { FieldSelect } from './field_select'; import { FormBasedLayer } from '../types'; +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + jest.mock('../operations'); describe('reference editor', () => { @@ -59,14 +69,6 @@ describe('reference editor', () => { paramEditorUpdater, selectionStyle: 'full' as const, currentIndexPattern: createMockedIndexPattern(), - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, dateRange: { fromDate: 'now-1d', toDate: 'now' }, storage: {} as IStorageWrapper, uiSettings: {} as IUiSettingsClient, diff --git a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx index cefee79349087..6b8ecbbfe5246 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/dimension_panel/reference_editor.tsx @@ -29,12 +29,7 @@ import { import { FieldChoiceWithOperationType, FieldSelect } from './field_select'; import { hasField } from '../pure_utils'; import type { FormBasedLayer } from '../types'; -import type { - ExistingFieldsMap, - IndexPattern, - IndexPatternField, - ParamEditorCustomProps, -} from '../../../types'; +import type { IndexPattern, IndexPatternField, ParamEditorCustomProps } from '../../../types'; import type { FormBasedDimensionEditorProps } from './dimension_panel'; import { FormRow } from '../operations/definitions/shared_components'; @@ -83,7 +78,6 @@ export interface ReferenceEditorProps { fieldLabel?: string; operationDefinitionMap: Record; isInline?: boolean; - existingFields: ExistingFieldsMap; dateRange: DateRange; labelAppend?: EuiFormRowProps['labelAppend']; isFullscreen: boolean; @@ -114,7 +108,6 @@ export interface ReferenceEditorProps { export const ReferenceEditor = (props: ReferenceEditorProps) => { const { currentIndexPattern, - existingFields, validation, selectionStyle, labelAppend, @@ -307,7 +300,6 @@ export const ReferenceEditor = (props: ReferenceEditorProps) => { ; - -function getDisplayedFieldsLength( - fieldGroups: FieldGroups, - accordionState: Partial> -) { - return Object.entries(fieldGroups) - .filter(([key]) => accordionState[key]) - .reduce((allFieldCount, [, { fields }]) => allFieldCount + fields.length, 0); -} - -export const FieldList = React.memo(function FieldList({ - exists, - fieldGroups, - existenceFetchFailed, - existenceFetchTimeout, - fieldProps, - hasSyncedExistingFields, - filter, - currentIndexPatternId, - existFieldsInIndex, - dropOntoWorkspace, - hasSuggestionForField, - editField, - removeField, - uiActions, -}: { - exists: (field: IndexPatternField) => boolean; - fieldGroups: FieldGroups; - fieldProps: FieldItemSharedProps; - hasSyncedExistingFields: boolean; - existenceFetchFailed?: boolean; - existenceFetchTimeout?: boolean; - filter: { - nameFilter: string; - typeFilter: string[]; - }; - currentIndexPatternId: string; - existFieldsInIndex: boolean; - dropOntoWorkspace: DatasourceDataPanelProps['dropOntoWorkspace']; - hasSuggestionForField: DatasourceDataPanelProps['hasSuggestionForField']; - editField?: (name: string) => void; - removeField?: (name: string) => void; - uiActions: UiActionsStart; -}) { - const [fieldGroupsToShow, fieldFroupsToCollapse] = partition( - Object.entries(fieldGroups), - ([, { showInAccordion }]) => showInAccordion - ); - const [pageSize, setPageSize] = useState(PAGINATION_SIZE); - const [scrollContainer, setScrollContainer] = useState(undefined); - const [accordionState, setAccordionState] = useState>>(() => - Object.fromEntries( - fieldGroupsToShow.map(([key, { isInitiallyOpen }]) => [key, isInitiallyOpen]) - ) - ); - - useEffect(() => { - // Reset the scroll if we have made material changes to the field list - if (scrollContainer) { - scrollContainer.scrollTop = 0; - setPageSize(PAGINATION_SIZE); - } - }, [filter.nameFilter, filter.typeFilter, currentIndexPatternId, scrollContainer]); - - const lazyScroll = useCallback(() => { - if (scrollContainer) { - const nearBottom = - scrollContainer.scrollTop + scrollContainer.clientHeight > - scrollContainer.scrollHeight * 0.9; - if (nearBottom) { - setPageSize( - Math.max( - PAGINATION_SIZE, - Math.min( - pageSize + PAGINATION_SIZE * 0.5, - getDisplayedFieldsLength(fieldGroups, accordionState) - ) - ) - ); - } - } - }, [scrollContainer, pageSize, setPageSize, fieldGroups, accordionState]); - - const paginatedFields = useMemo(() => { - let remainingItems = pageSize; - return Object.fromEntries( - fieldGroupsToShow.map(([key, fieldGroup]) => { - if (!accordionState[key] || remainingItems <= 0) { - return [key, []]; - } - const slicedFieldList = fieldGroup.fields.slice(0, remainingItems); - remainingItems = remainingItems - slicedFieldList.length; - return [key, slicedFieldList]; - }) - ); - }, [pageSize, fieldGroupsToShow, accordionState]); - - return ( -
      { - if (el && !el.dataset.dynamicScroll) { - el.dataset.dynamicScroll = 'true'; - setScrollContainer(el); - } - }} - onScroll={throttle(lazyScroll, 100)} - > -
      -
        - {fieldFroupsToCollapse.flatMap(([, { fields }]) => - fields.map((field, index) => ( - - )) - )} -
      - - {fieldGroupsToShow.map(([key, fieldGroup], index) => { - if (Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length) return null; - return ( - - { - setAccordionState((s) => ({ - ...s, - [key]: open, - })); - const displayedFieldLength = getDisplayedFieldsLength(fieldGroups, { - ...accordionState, - [key]: open, - }); - setPageSize( - Math.max(PAGINATION_SIZE, Math.min(pageSize * 1.5, displayedFieldLength)) - ); - }} - showExistenceFetchError={existenceFetchFailed} - showExistenceFetchTimeout={existenceFetchTimeout} - renderCallout={ - - } - uiActions={uiActions} - /> - - - ); - })} -
      -
      - ); -}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx deleted file mode 100644 index a471f8e0fa309..0000000000000 --- a/x-pack/plugins/lens/public/datasources/form_based/fields_accordion.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiLoadingSpinner, EuiNotificationBadge } from '@elastic/eui'; -import { coreMock } from '@kbn/core/public/mocks'; -import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers'; -import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; -import { IndexPattern } from '../../types'; -import { FieldItem } from './field_item'; -import { FieldsAccordion, FieldsAccordionProps, FieldItemSharedProps } from './fields_accordion'; -import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; -import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; - -describe('Fields Accordion', () => { - let defaultProps: FieldsAccordionProps; - let indexPattern: IndexPattern; - let core: ReturnType; - let fieldProps: FieldItemSharedProps; - - beforeEach(() => { - indexPattern = { - id: '1', - title: 'my-fake-index-pattern', - timeFieldName: 'timestamp', - fields: [ - { - name: 'timestamp', - type: 'date', - aggregatable: true, - searchable: true, - }, - { - name: 'bytes', - type: 'number', - aggregatable: true, - searchable: true, - }, - ], - } as IndexPattern; - core = coreMock.createStart(); - core.http.post.mockClear(); - - fieldProps = { - indexPattern, - fieldFormats: fieldFormatsServiceMock.createStartContract(), - core, - highlight: '', - dateRange: { - fromDate: 'now-7d', - toDate: 'now', - }, - query: { query: '', language: 'lucene' }, - filters: [], - chartsThemeService: chartPluginMock.createSetupContract().theme, - }; - - defaultProps = { - initialIsOpen: true, - onToggle: jest.fn(), - id: 'id', - label: 'label', - hasLoaded: true, - fieldsCount: 2, - isFiltered: false, - paginatedFields: indexPattern.fields, - fieldProps, - renderCallout:
      Callout
      , - exists: () => true, - groupIndex: 0, - dropOntoWorkspace: () => {}, - hasSuggestionForField: () => false, - uiActions: uiActionsPluginMock.createStartContract(), - }; - }); - - it('renders correct number of Field Items', () => { - const wrapper = mountWithIntl( - field.name === 'timestamp'} /> - ); - expect(wrapper.find(FieldItem).at(0).prop('exists')).toEqual(true); - expect(wrapper.find(FieldItem).at(1).prop('exists')).toEqual(false); - }); - - it('passed correct exists flag to each field', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(FieldItem).length).toEqual(2); - }); - - it('renders callout if no fields', () => { - const wrapper = shallowWithIntl( - - ); - expect(wrapper.find('#lens-test-callout').length).toEqual(1); - }); - - it('renders accented notificationBadge state if isFiltered', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiNotificationBadge).prop('color')).toEqual('accent'); - }); - - it('renders spinner if has not loaded', () => { - const wrapper = mountWithIntl(); - expect(wrapper.find(EuiLoadingSpinner).length).toEqual(1); - }); -}); diff --git a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx index defc505f1d9e1..86fd5490f383b 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/layerpanel.test.tsx @@ -181,8 +181,6 @@ describe('Layer Data Panel', () => { { id: '2', title: 'my-fake-restricted-pattern' }, { id: '3', title: 'my-compatible-pattern' }, ], - existingFields: {}, - isFirstExistenceFetch: false, indexPatterns: { '1': { id: '1', diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx index c08f8703c723f..d9510256eb92d 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/date_histogram.test.tsx @@ -113,14 +113,6 @@ const defaultOptions = { isFullscreen: false, toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('date_histogram', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx index 2264fa8f185fb..e5199a5295ec6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/filters/filters.test.tsx @@ -38,14 +38,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; // mocking random id generator function diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts index 5253267b286cd..1ed621b19b8bd 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/index.ts @@ -198,7 +198,6 @@ export interface ParamEditorProps< activeData?: FormBasedDimensionEditorProps['activeData']; operationDefinitionMap: Record; paramEditorCustomProps?: ParamEditorCustomProps; - existingFields: Record>; isReferenced?: boolean; } @@ -215,10 +214,6 @@ export interface FieldInputProps { incompleteParams: Omit; dimensionGroups: FormBasedDimensionEditorProps['dimensionGroups']; groupId: FormBasedDimensionEditorProps['groupId']; - /** - * indexPatternId -> fieldName -> boolean - */ - existingFields: Record>; operationSupportMatrix: OperationSupportMatrix; helpMessage?: React.ReactNode; operationDefinitionMap: Record; diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx index 16c6f2727ea50..cf5babde1feb6 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/last_value.test.tsx @@ -44,14 +44,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('last_value', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx index e78ac9e9360da..59d8602d7c275 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile.test.tsx @@ -60,14 +60,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('percentile', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx index adb0d8e491fd7..c29e5ca2c1499 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/percentile_ranks.test.tsx @@ -53,14 +53,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('percentile ranks', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx index d238fd16b8932..e5a870985d2c0 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/ranges/ranges.test.tsx @@ -86,14 +86,6 @@ const defaultOptions = { storage: {} as IStorageWrapper, uiSettings: uiSettingsMock, savedObjectsClient: {} as SavedObjectsClientContract, - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, dateRange: { fromDate: 'now-1y', toDate: 'now', diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx index 0ed6a60677f73..6d79d19f44a53 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/static_value.test.tsx @@ -52,14 +52,6 @@ const defaultProps = { toggleFullscreen: jest.fn(), setIsCloseable: jest.fn(), layerId: '1', - existingFields: { - my_index_pattern: { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('static_value', () => { diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx index 17b0e5e475ffd..b7f24e7d3d9c1 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/field_inputs.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import { htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { ExistingFieldsMap, IndexPattern } from '../../../../../types'; +import { IndexPattern } from '../../../../../types'; import { DragDropBuckets, FieldsBucketContainer, @@ -27,7 +27,6 @@ export const MAX_MULTI_FIELDS_SIZE = 3; export interface FieldInputsProps { column: TermsIndexPatternColumn; indexPattern: IndexPattern; - existingFields: ExistingFieldsMap; invalidFields?: string[]; operationSupportMatrix: Pick; onChange: (newValues: string[]) => void; @@ -49,7 +48,6 @@ export function FieldInputs({ column, onChange, indexPattern, - existingFields, operationSupportMatrix, invalidFields, }: FieldInputsProps) { @@ -153,7 +151,6 @@ export function FieldInputs({ { throw new Error('Should not be called'); }} diff --git a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx index 58f2f479f401a..d7a8770111080 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/operations/definitions/terms/terms.test.tsx @@ -50,6 +50,16 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({ }), })); +jest.mock('@kbn/unified-field-list-plugin/public/hooks/use_existing_fields', () => ({ + useExistingFieldsReader: jest.fn(() => { + return { + hasFieldData: (dataViewId: string, fieldName: string) => { + return ['timestamp', 'bytes', 'memory', 'source'].includes(fieldName); + }, + }; + }), +})); + // mocking random id generator function jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -93,14 +103,6 @@ const defaultProps = { setIsCloseable: jest.fn(), layerId: '1', ReferenceEditor, - existingFields: { - 'my-fake-index-pattern': { - timestamp: true, - bytes: true, - memory: true, - source: true, - }, - }, }; describe('terms', () => { @@ -1170,20 +1172,7 @@ describe('terms', () => { >, }; - function getExistingFields() { - const fields: Record = {}; - for (const field of defaultProps.indexPattern.fields) { - fields[field.name] = true; - } - return { - [defaultProps.indexPattern.title]: fields, - }; - } - - function getDefaultOperationSupportMatrix( - columnId: string, - existingFields: Record> - ) { + function getDefaultOperationSupportMatrix(columnId: string) { return getOperationSupportMatrix({ state: { layers: { layer1: layer }, @@ -1199,15 +1188,13 @@ describe('terms', () => { it('should render the default field input for no field (incomplete operation)', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1226,8 +1213,7 @@ describe('terms', () => { it('should show an error message when first field is invalid', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of unsupported', @@ -1247,7 +1233,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} currentFieldIsInvalid /> @@ -1259,8 +1244,7 @@ describe('terms', () => { it('should show an error message when first field is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of timestamp', @@ -1280,7 +1264,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} incompleteOperation="terms" @@ -1293,8 +1276,7 @@ describe('terms', () => { it('should show an error message when any field but the first is invalid', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of geo.src + 1 other', @@ -1315,7 +1297,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1327,8 +1308,7 @@ describe('terms', () => { it('should show an error message when any field but the first is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'Top value of geo.src + 1 other', @@ -1349,7 +1329,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1361,15 +1340,13 @@ describe('terms', () => { it('should render the an add button for single layer and disabled the remove button', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1392,15 +1369,13 @@ describe('terms', () => { it('should switch to the first supported operation when in single term mode and the picked field is not supported', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( @@ -1426,8 +1401,7 @@ describe('terms', () => { it('should render the multi terms specific UI', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['bytes']; const instance = mount( @@ -1436,7 +1410,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1457,8 +1430,7 @@ describe('terms', () => { it('should return to single value UI when removing second item of two', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1467,7 +1439,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1489,8 +1460,7 @@ describe('terms', () => { it('should disable remove button and reorder drag when single value and one temporary new field', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1532,8 +1501,7 @@ describe('terms', () => { it('should accept scripted fields for single value', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; const instance = mount( @@ -1542,7 +1510,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1558,8 +1525,7 @@ describe('terms', () => { it('should mark scripted fields for multiple values', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).sourceField = 'scripted'; (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; @@ -1569,7 +1535,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1588,8 +1553,7 @@ describe('terms', () => { it('should not filter scripted fields when in single value', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); const instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1618,8 +1581,7 @@ describe('terms', () => { it('should filter scripted fields when in multi terms mode', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1628,7 +1590,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1650,8 +1611,7 @@ describe('terms', () => { it('should filter already used fields when displaying fields list', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory', 'bytes']; let instance = mount( @@ -1660,7 +1620,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1690,8 +1649,7 @@ describe('terms', () => { it('should filter fields with unsupported types when in multi terms mode', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = ['memory']; const instance = mount( @@ -1700,7 +1658,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1722,8 +1679,7 @@ describe('terms', () => { it('should limit the number of multiple fields', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); (layer.columns.col1 as TermsIndexPatternColumn).params.secondaryFields = [ 'memory', @@ -1736,7 +1692,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1757,8 +1712,7 @@ describe('terms', () => { it('should let the user add new empty field up to the limit', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1793,8 +1746,7 @@ describe('terms', () => { it('should update the parentFormatter on transition between single to multi terms', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); let instance = mount( { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> @@ -1834,8 +1785,7 @@ describe('terms', () => { it('should preserve custom label when set by the user', () => { const updateLayerSpy = jest.fn(); - const existingFields = getExistingFields(); - const operationSupportMatrix = getDefaultOperationSupportMatrix('col1', existingFields); + const operationSupportMatrix = getDefaultOperationSupportMatrix('col1'); layer.columns.col1 = { label: 'MyCustomLabel', @@ -1857,7 +1807,6 @@ describe('terms', () => { layer={layer} updateLayer={updateLayerSpy} columnId="col1" - existingFields={existingFields} operationSupportMatrix={operationSupportMatrix} selectedColumn={layer.columns.col1 as TermsIndexPatternColumn} /> diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx index b5f158ecd453f..cac4fc380cc99 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/datapanel.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; import type { Query } from '@kbn/es-query'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; @@ -30,7 +31,6 @@ import { createIndexPatternServiceMock } from '../../mocks/data_views_service_mo import { createMockFramePublicAPI } from '../../mocks'; import { createMockedDragDropContext } from './mocks'; import { DataViewsState } from '../../state_management'; -import { ExistingFieldsMap, IndexPattern } from '../../types'; const fieldsFromQuery = [ { @@ -101,18 +101,6 @@ const fieldsOne = [ }, ]; -function getExistingFields(indexPatterns: Record) { - const existingFields: ExistingFieldsMap = {}; - for (const { title, fields } of Object.values(indexPatterns)) { - const fieldsMap: Record = {}; - for (const { displayName, name } of fields) { - fieldsMap[displayName ?? name] = true; - } - existingFields[title] = fieldsMap; - } - return existingFields; -} - const initialState: TextBasedPrivateState = { layers: { first: { @@ -130,27 +118,16 @@ const initialState: TextBasedPrivateState = { fieldList: fieldsFromQuery, }; -function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial = {}) { +function getFrameAPIMock({ + indexPatterns, + ...rest +}: Partial & { indexPatterns: DataViewsState['indexPatterns'] }) { const frameAPI = createMockFramePublicAPI(); - const defaultIndexPatterns = { - '1': { - id: '1', - title: 'idx1', - timeFieldName: 'timestamp', - hasRestrictions: false, - fields: fieldsOne, - getFieldByName: jest.fn(), - isPersisted: true, - spec: {}, - }, - }; return { ...frameAPI, dataViews: { ...frameAPI.dataViews, - indexPatterns: indexPatterns ?? defaultIndexPatterns, - existingFields: existingFields ?? getExistingFields(indexPatterns ?? defaultIndexPatterns), - isFirstExistenceFetch: false, + indexPatterns, ...rest, }, }; @@ -159,12 +136,39 @@ function getFrameAPIMock({ indexPatterns, existingFields, ...rest }: Partial element); +async function mountAndWaitForLazyModules(component: React.ReactElement): Promise { + let inst: ReactWrapper; + await act(async () => { + inst = await mountWithIntl(component); + // wait for lazy modules + await new Promise((resolve) => setTimeout(resolve, 0)); + inst.update(); + }); + + await inst!.update(); + + return inst!; +} + describe('TextBased Query Languages Data Panel', () => { let core: ReturnType; let dataViews: DataViewPublicStart; + const defaultIndexPatterns = { + '1': { + id: '1', + title: 'idx1', + timeFieldName: 'timestamp', + hasRestrictions: false, + fields: fieldsOne, + getFieldByName: jest.fn(), + isPersisted: true, + spec: {}, + }, + }; let defaultProps: TextBasedDataPanelProps; const dataViewsMock = dataViewPluginMocks.createStartContract(); + beforeEach(() => { core = coreMock.createStart(); dataViews = dataViewPluginMocks.createStartContract(); @@ -194,7 +198,7 @@ describe('TextBased Query Languages Data Panel', () => { hasSuggestionForField: jest.fn(() => false), uiActions: uiActionsPluginMock.createStartContract(), indexPatternService: createIndexPatternServiceMock({ core, dataViews }), - frame: getFrameAPIMock(), + frame: getFrameAPIMock({ indexPatterns: defaultIndexPatterns }), state: initialState, setState: jest.fn(), onChangeIndexPattern: jest.fn(), @@ -202,23 +206,33 @@ describe('TextBased Query Languages Data Panel', () => { }); it('should render a search box', async () => { - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]').length).toEqual(1); + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesFieldSearch"]').length).toEqual(1); }); it('should list all supported fields in the pattern', async () => { - const wrapper = mountWithIntl(); + const wrapper = await mountAndWaitForLazyModules(); + expect( wrapper - .find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]') + .find('[data-test-subj="lnsTextBasedLanguagesAvailableFields"]') .find(FieldButton) .map((fieldItem) => fieldItem.prop('fieldName')) - ).toEqual(['timestamp', 'bytes', 'memory']); + ).toEqual(['bytes', 'memory', 'timestamp']); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesEmptyFields"]').exists()).toBe( + false + ); + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesMetaFields"]').exists()).toBe(false); }); it('should not display the selected fields accordion if there are no fields displayed', async () => { - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsSelectedFieldsTextBased"]').length).toEqual(0); + const wrapper = await mountAndWaitForLazyModules(); + + expect(wrapper.find('[data-test-subj="lnsTextBasedLanguagesSelectedFields"]').length).toEqual( + 0 + ); }); it('should display the selected fields accordion if there are fields displayed', async () => { @@ -226,13 +240,17 @@ describe('TextBased Query Languages Data Panel', () => { ...defaultProps, layerFields: ['memory'], }; - const wrapper = mountWithIntl(); - expect(wrapper.find('[data-test-subj="lnsSelectedFieldsTextBased"]').length).not.toEqual(0); + const wrapper = await mountAndWaitForLazyModules(); + + expect( + wrapper.find('[data-test-subj="lnsTextBasedLanguagesSelectedFields"]').length + ).not.toEqual(0); }); it('should list all supported fields in the pattern that match the search input', async () => { - const wrapper = mountWithIntl(); - const searchBox = wrapper.find('[data-test-subj="lnsTextBasedLangugesFieldSearch"]'); + const wrapper = await mountAndWaitForLazyModules(); + + const searchBox = wrapper.find('[data-test-subj="lnsTextBasedLanguagesFieldSearch"]'); act(() => { searchBox.prop('onChange')!({ @@ -240,10 +258,10 @@ describe('TextBased Query Languages Data Panel', () => { } as React.ChangeEvent); }); - wrapper.update(); + await wrapper.update(); expect( wrapper - .find('[data-test-subj="lnsTextBasedLanguagesPanelFields"]') + .find('[data-test-subj="lnsTextBasedLanguagesAvailableFields"]') .find(FieldButton) .map((fieldItem) => fieldItem.prop('fieldName')) ).toEqual(['memory']); diff --git a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx b/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx index 1b0699b2eb930..0416d163670fb 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/datapanel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiFormControlLayout, htmlIdGenerator } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import usePrevious from 'react-use/lib/usePrevious'; @@ -14,13 +14,22 @@ import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { isOfAggregateQueryType } from '@kbn/es-query'; -import { ExpressionsStart } from '@kbn/expressions-plugin/public'; +import { DatatableColumn, ExpressionsStart } from '@kbn/expressions-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { + ExistenceFetchStatus, + FieldListGrouped, + FieldListGroupedProps, + FieldsGroupNames, + useGroupedFields, +} from '@kbn/unified-field-list-plugin/public'; +import { FieldButton } from '@kbn/react-field'; import type { DatasourceDataPanelProps } from '../../types'; import type { TextBasedPrivateState } from './types'; import { getStateFromAggregateQuery } from './utils'; -import { ChildDragDropProvider } from '../../drag_drop'; -import { FieldsAccordion } from './fields_accordion'; +import { ChildDragDropProvider, DragDrop } from '../../drag_drop'; +import { DataType } from '../../types'; +import { LensFieldIcon } from '../../shared_components'; export type TextBasedDataPanelProps = DatasourceDataPanelProps & { data: DataPublicPluginStart; @@ -67,8 +76,16 @@ export function TextBasedDataPanel({ }, [data, dataViews, expressions, prevQuery, query, setState, state]); const { fieldList } = state; - const filteredFields = useMemo(() => { - return fieldList.filter((field) => { + + const onSelectedFieldFilter = useCallback( + (field: DatatableColumn): boolean => { + return Boolean(layerFields?.includes(field.name)); + }, + [layerFields] + ); + + const onFilterField = useCallback( + (field: DatatableColumn) => { if ( localState.nameFilter && !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) @@ -76,9 +93,57 @@ export function TextBasedDataPanel({ return false; } return true; - }); - }, [fieldList, localState.nameFilter]); - const usedByLayersFields = fieldList.filter((field) => layerFields?.includes(field.name)); + }, + [localState] + ); + + const onOverrideFieldGroupDetails = useCallback((groupName) => { + if (groupName === FieldsGroupNames.AvailableFields) { + return { + helpText: i18n.translate('xpack.lens.indexPattern.allFieldsForTextBasedLabelHelp', { + defaultMessage: + 'Drag and drop available fields to the workspace and create visualizations. To change the available fields, edit your query.', + }), + }; + } + }, []); + + const { fieldGroups } = useGroupedFields({ + dataViewId: null, + allFields: fieldList, + services: { + dataViews, + }, + onFilterField, + onSelectedFieldFilter, + onOverrideFieldGroupDetails, + }); + + const renderFieldItem: FieldListGroupedProps['renderFieldItem'] = useCallback( + ({ field, itemIndex, groupIndex, hideDetails }) => { + return ( + + {}} + fieldIcon={} + fieldName={field?.name} + /> + + ); + }, + [] + ); return ( -
      -
      - {usedByLayersFields.length > 0 && ( - - )} - -
      -
      + + fieldGroups={fieldGroups} + fieldsExistenceStatus={ + dataHasLoaded ? ExistenceFetchStatus.succeeded : ExistenceFetchStatus.unknown + } + fieldsExistInIndex={Boolean(fieldList.length)} + renderFieldItem={renderFieldItem} + screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId} + data-test-subj="lnsTextBasedLanguages" + />
      diff --git a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx b/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx deleted file mode 100644 index d02fd98bc9c87..0000000000000 --- a/x-pack/plugins/lens/public/datasources/text_based/fields_accordion.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useMemo } from 'react'; -import type { DatatableColumn } from '@kbn/expressions-plugin/public'; - -import { - EuiText, - EuiNotificationBadge, - EuiAccordion, - EuiLoadingSpinner, - EuiSpacer, -} from '@elastic/eui'; -import { FieldButton } from '@kbn/react-field'; -import { DragDrop } from '../../drag_drop'; -import { LensFieldIcon } from '../../shared_components'; -import type { DataType } from '../../types'; - -export interface FieldsAccordionProps { - initialIsOpen: boolean; - hasLoaded: boolean; - isFiltered: boolean; - // forceState: 'open' | 'closed'; - id: string; - label: string; - fields: DatatableColumn[]; -} - -export const FieldsAccordion = memo(function InnerFieldsAccordion({ - initialIsOpen, - hasLoaded, - isFiltered, - id, - label, - fields, -}: FieldsAccordionProps) { - const renderButton = useMemo(() => { - return ( - - {label} - - ); - }, [label]); - - const extraAction = useMemo(() => { - if (hasLoaded) { - return ( - - {fields.length} - - ); - } - - return ; - }, [fields.length, hasLoaded, id, isFiltered]); - - return ( - <> - -
        - {fields.length > 0 && - fields.map((field, index) => ( -
      • - - {}} - fieldIcon={} - fieldName={field?.name} - /> - -
      • - ))} -
      -
      - - - ); -}); diff --git a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx index f0a9d147ddfd6..bc2d64e8ac55d 100644 --- a/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/datasources/text_based/layerpanel.test.tsx @@ -68,8 +68,6 @@ describe('Layer Data Panel', () => { { id: '2', title: 'my-fake-restricted-pattern', name: 'my-fake-restricted-pattern' }, { id: '3', title: 'my-compatible-pattern', name: 'my-compatible-pattern' }, ], - existingFields: {}, - isFirstExistenceFetch: false, indexPatterns: {}, } as DataViewsState, }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index acbeb79bbe74d..13939fa276b31 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -568,7 +568,6 @@ export function LayerPanel( invalid: group.invalid, invalidMessage: group.invalidMessage, indexPatterns: dataViews.indexPatterns, - existingFields: dataViews.existingFields, }} /> ) : ( @@ -728,7 +727,6 @@ export function LayerPanel( formatSelectorOptions: activeGroup.formatSelectorOptions, layerType: activeVisualization.getLayerType(layerId, visualizationState), indexPatterns: dataViews.indexPatterns, - existingFields: dataViews.existingFields, activeData: layerVisualizationConfigProps.activeData, }} /> diff --git a/x-pack/plugins/lens/public/mocks/store_mocks.tsx b/x-pack/plugins/lens/public/mocks/store_mocks.tsx index 8320f429e9d5a..d4ba2d042b1ca 100644 --- a/x-pack/plugins/lens/public/mocks/store_mocks.tsx +++ b/x-pack/plugins/lens/public/mocks/store_mocks.tsx @@ -59,8 +59,6 @@ export const defaultState = { dataViews: { indexPatterns: {}, indexPatternRefs: [], - existingFields: {}, - isFirstExistenceFetch: false, }, }; diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts b/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts index 4f53930fa4973..febf8b1d7c500 100644 --- a/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/helpers.ts @@ -5,25 +5,17 @@ * 2.0. */ +import { type ExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { IndexPattern } from '../../types'; /** * Checks if the provided field contains data (works for meta field) */ export function fieldContainsData( - field: string, + fieldName: string, indexPattern: IndexPattern, - existingFields: Record + hasFieldData: ExistingFieldsReader['hasFieldData'] ) { - return ( - indexPattern.getFieldByName(field)?.type === 'document' || fieldExists(existingFields, field) - ); -} - -/** - * Performs an existence check on the existingFields data structure for the provided field. - * Does not work for meta fields. - */ -export function fieldExists(existingFields: Record, fieldName: string) { - return existingFields[fieldName]; + const field = indexPattern.getFieldByName(fieldName); + return field?.type === 'document' || hasFieldData(indexPattern.id, fieldName); } diff --git a/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts b/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts index 4de03b2f8b92c..6bf23c9d414db 100644 --- a/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts +++ b/x-pack/plugins/lens/public/shared_components/dataview_picker/index.ts @@ -6,4 +6,4 @@ */ export { ChangeIndexPattern } from './dataview_picker'; -export { fieldExists, fieldContainsData } from './helpers'; +export { fieldContainsData } from './helpers'; diff --git a/x-pack/plugins/lens/public/shared_components/index.ts b/x-pack/plugins/lens/public/shared_components/index.ts index a2fcc9c54882d..e57a18b3ee2ee 100644 --- a/x-pack/plugins/lens/public/shared_components/index.ts +++ b/x-pack/plugins/lens/public/shared_components/index.ts @@ -11,7 +11,7 @@ export { LegendSettingsPopover } from './legend_settings_popover'; export { PalettePicker } from './palette_picker'; export { FieldPicker, LensFieldIcon, TruncatedLabel } from './field_picker'; export type { FieldOption, FieldOptionValue } from './field_picker'; -export { ChangeIndexPattern, fieldExists, fieldContainsData } from './dataview_picker'; +export { ChangeIndexPattern, fieldContainsData } from './dataview_picker'; export { QueryInput, isQueryValid, validateQuery } from './query_input'; export { NewBucketButton, diff --git a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap index a6759521f562e..d30a68e5e52b0 100644 --- a/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap +++ b/x-pack/plugins/lens/public/state_management/__snapshots__/load_initial.test.tsx.snap @@ -5,10 +5,8 @@ Object { "lens": Object { "activeDatasourceId": "testDatasource", "dataViews": Object { - "existingFields": Object {}, "indexPatternRefs": Array [], "indexPatterns": Object {}, - "isFirstExistenceFetch": true, }, "datasourceStates": Object { "testDatasource": Object { diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts index f21d1e6c4aa1a..e8874fbcda822 100644 --- a/x-pack/plugins/lens/public/state_management/lens_slice.ts +++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts @@ -50,8 +50,6 @@ export const initialState: LensAppState = { dataViews: { indexPatternRefs: [], indexPatterns: {}, - existingFields: {}, - isFirstExistenceFetch: true, }, }; diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts index 9399506f5fca1..4f7500ec20a5e 100644 --- a/x-pack/plugins/lens/public/state_management/types.ts +++ b/x-pack/plugins/lens/public/state_management/types.ts @@ -30,10 +30,6 @@ export interface VisualizationState { export interface DataViewsState { indexPatternRefs: IndexPatternRef[]; indexPatterns: Record; - existingFields: Record>; - isFirstExistenceFetch: boolean; - existenceFetchFailed?: boolean; - existenceFetchTimeout?: boolean; } export type DatasourceStates = Record; diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index a8389c7841712..628afa8d61276 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -108,7 +108,6 @@ export interface EditorFrameProps { export type VisualizationMap = Record; export type DatasourceMap = Record; export type IndexPatternMap = Record; -export type ExistingFieldsMap = Record>; export interface EditorFrameInstance { EditorFrameContainer: (props: EditorFrameProps) => React.ReactElement; @@ -589,7 +588,6 @@ export type DatasourceDimensionProps = SharedDimensionProps & { state: T; activeData?: Record; indexPatterns: IndexPatternMap; - existingFields: Record>; hideTooltip?: boolean; invalid?: boolean; invalidMessage?: string; diff --git a/x-pack/plugins/lens/public/utils.ts b/x-pack/plugins/lens/public/utils.ts index efa0d3c226468..619c9f7d71f30 100644 --- a/x-pack/plugins/lens/public/utils.ts +++ b/x-pack/plugins/lens/public/utils.ts @@ -80,8 +80,6 @@ export function getInitialDataViewsObject( return { indexPatterns, indexPatternRefs, - existingFields: {}, - isFirstExistenceFetch: true, }; } @@ -107,9 +105,6 @@ export async function refreshIndexPatternsList({ onIndexPatternRefresh: () => onRefreshCallbacks.forEach((fn) => fn()), }); const indexPattern = newlyMappedIndexPattern[indexPatternId]; - // But what about existingFields here? - // When the indexPatterns cache object gets updated, the data panel will - // notice it and refetch the fields list existence map indexPatternService.updateDataViewsState({ indexPatterns: { ...indexPatternsCache, diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx index 480c0773f4520..748217469ce63 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/annotations_panel.tsx @@ -23,6 +23,7 @@ import { QueryPointEventAnnotationConfig, } from '@kbn/event-annotation-plugin/common'; import moment from 'moment'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { FieldOption, FieldOptionValue, @@ -31,7 +32,6 @@ import { import { FormatFactory } from '../../../../../common'; import { DimensionEditorSection, - fieldExists, NameInput, useDebouncedValue, } from '../../../../shared_components'; @@ -58,6 +58,7 @@ export const AnnotationsPanel = ( ) => { const { state, setState, layerId, accessor, frame } = props; const isHorizontal = isHorizontalChart(state.layers); + const { hasFieldData } = useExistingFieldsReader(); const { inputValue: localState, handleInputChange: setLocalState } = useDebouncedValue({ value: state, @@ -248,10 +249,7 @@ export const AnnotationsPanel = ( field: field.name, dataType: field.type, }, - exists: fieldExists( - frame.dataViews.existingFields[currentIndexPattern.title], - field.name - ), + exists: hasFieldData(currentIndexPattern.id, field.name), compatible: true, 'data-test-subj': `lnsXY-annotation-fieldOption-${field.name}`, } as FieldOption) @@ -379,7 +377,6 @@ export const AnnotationsPanel = ( currentConfig={currentAnnotation} setConfig={setAnnotations} indexPattern={frame.dataViews.indexPatterns[localLayer.indexPatternId]} - existingFields={frame.dataViews.existingFields} /> diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx index 0ee0d1f06d1c8..00f0013c92822 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/query_annotation_panel.tsx @@ -10,8 +10,8 @@ import type { Query } from '@kbn/data-plugin/common'; import type { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; import { i18n } from '@kbn/i18n'; import React from 'react'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; import { - fieldExists, FieldOption, FieldOptionValue, FieldPicker, @@ -41,7 +41,7 @@ export const ConfigPanelQueryAnnotation = ({ queryInputShouldOpen?: boolean; }) => { const currentIndexPattern = frame.dataViews.indexPatterns[layer.indexPatternId]; - const currentExistingFields = frame.dataViews.existingFields[currentIndexPattern.title]; + const { hasFieldData } = useExistingFieldsReader(); // list only date fields const options = currentIndexPattern.fields .filter((field) => field.type === 'date' && field.displayName) @@ -53,7 +53,7 @@ export const ConfigPanelQueryAnnotation = ({ field: field.name, dataType: field.type, }, - exists: fieldExists(currentExistingFields, field.name), + exists: hasFieldData(currentIndexPattern.id, field.name), compatible: true, 'data-test-subj': `lns-fieldOption-${field.name}`, } as FieldOption; diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx index 20a99e8458fc0..d3f68686c3bac 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/annotations_config_panel/tooltip_annotation_panel.tsx @@ -9,9 +9,9 @@ import { htmlIdGenerator, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo } from 'react'; import { QueryPointEventAnnotationConfig } from '@kbn/event-annotation-plugin/common'; -import type { ExistingFieldsMap, IndexPattern } from '../../../../types'; +import { useExistingFieldsReader } from '@kbn/unified-field-list-plugin/public'; +import type { IndexPattern } from '../../../../types'; import { - fieldExists, FieldOption, FieldOptionValue, FieldPicker, @@ -31,7 +31,6 @@ export interface FieldInputsProps { currentConfig: QueryPointEventAnnotationConfig; setConfig: (config: QueryPointEventAnnotationConfig) => void; indexPattern: IndexPattern; - existingFields: ExistingFieldsMap; invalidFields?: string[]; } @@ -51,9 +50,9 @@ export function TooltipSection({ currentConfig, setConfig, indexPattern, - existingFields, invalidFields, }: FieldInputsProps) { + const { hasFieldData } = useExistingFieldsReader(); const onChangeWrapped = useCallback( (values: WrappedValue[]) => { setConfig({ @@ -124,7 +123,6 @@ export function TooltipSection({ ); } - const currentExistingField = existingFields[indexPattern.title]; const options = indexPattern.fields .filter( @@ -140,7 +138,7 @@ export function TooltipSection({ field: field.name, dataType: field.type, }, - exists: fieldExists(currentExistingField, field.name), + exists: hasFieldData(indexPattern.id, field.name), compatible: true, 'data-test-subj': `lnsXY-annotation-tooltip-fieldOption-${field.name}`, } as FieldOption) diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 087c56fab47b9..f77bd0fd05796 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -17250,7 +17250,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label} utilise un décalage temporel de {columnTimeShift} qui est inférieur à l'intervalle de l'histogramme des dates de {interval}. Pour éviter une non-correspondance des données, utilisez un multiple de {interval} comme décalage.", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "Nombre de {name}", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} {availableFields, plural, one {champ} other {champs}} disponible(s). {emptyFields} {emptyFields, plural, one {champ} other {champs}} vide(s). {metaFields} {metaFields, plural, one {champ} other {champs}} méta.", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "Afficher uniquement {indexPatternTitle}", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "Afficher uniquement le calque {layerNumber}", "xpack.lens.modalTitle.title.clear": "Effacer le calque {layerType} ?", @@ -17558,7 +17557,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "La configuration de l'axe horizontal est manquante.", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "Axe horizontal manquant.", "xpack.lens.indexPattern.advancedSettings": "Avancé", - "xpack.lens.indexPattern.allFieldsLabel": "Tous les champs", "xpack.lens.indexPattern.allFieldsLabelHelp": "Glissez-déposez les champs disponibles dans l’espace de travail et créez des visualisations. Pour modifier les champs disponibles, sélectionnez une vue de données différente, modifiez vos requêtes ou utilisez une plage temporelle différente. Certains types de champ ne peuvent pas être visualisés dans Lens, y compris les champ de texte intégral et champs géographiques.", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "Les champs disponibles contiennent les données des 500 premiers documents correspondant aux filtres. Pour afficher tous les filtres, développez les champs vides. Vous ne pouvez pas créer de visualisations avec des champs de texte intégral, géographiques, lissés et d’objet.", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "veuillez consulter la documentation", @@ -17605,12 +17603,7 @@ "xpack.lens.indexPattern.differences.signature": "indicateur : nombre", "xpack.lens.indexPattern.emptyDimensionButton": "Dimension vide", "xpack.lens.indexPattern.emptyFieldsLabel": "Champs vides", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "Les champs vides ne contenaient aucune valeur dans les 500 premiers documents basés sur vos filtres.", "xpack.lens.indexPattern.enableAccuracyMode": "Activer le mode de précision", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "La récupération de l'existence a échoué", - "xpack.lens.indexPattern.existenceErrorLabel": "Impossible de charger les informations de champ", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "La récupération de l'existence a expiré", - "xpack.lens.indexPattern.existenceTimeoutLabel": "Les informations de champ ont pris trop de temps", "xpack.lens.indexPattern.fieldItemTooltip": "Effectuez un glisser-déposer pour visualiser.", "xpack.lens.indexPattern.fieldPlaceholder": "Champ", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "Ce champ ne comporte aucune donnée mais vous pouvez toujours effectuer un glisser-déposer pour visualiser.", @@ -17788,16 +17781,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "Regrouper d'abord en fonction de ce champ", "xpack.lens.indexPatterns.clearFiltersLabel": "Effacer le nom et saisissez les filtres", "xpack.lens.indexPatterns.filterByNameLabel": "Rechercher les noms de champs", - "xpack.lens.indexPatterns.noAvailableDataLabel": "Aucun champ disponible ne contient de données.", - "xpack.lens.indexPatterns.noDataLabel": "Aucun champ.", - "xpack.lens.indexPatterns.noEmptyDataLabel": "Aucun champ vide.", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "Extension de la plage temporelle", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "Utilisation de différents filtres de champ", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "Modification des filtres globaux", - "xpack.lens.indexPatterns.noFields.tryText": "Essayer :", - "xpack.lens.indexPatterns.noFieldsLabel": "Aucun champ n'existe dans cette vue de données.", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "Aucun champ ne correspond aux filtres sélectionnés.", - "xpack.lens.indexPatterns.noMetaDataLabel": "Aucun champ méta.", "xpack.lens.label.gauge.labelMajor.header": "Titre", "xpack.lens.label.gauge.labelMinor.header": "Sous-titre", "xpack.lens.label.header": "Étiquette", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index f4c281167d8b6..3da599c4ebd22 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17231,7 +17231,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label}は{columnTimeShift}の時間シフトを使用しています。これは{interval}の日付ヒストグラム間隔よりも小さいです。不一致のデータを防止するには、時間シフトとして{interval}を使用します。", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "{name}のカウント", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields}使用可能な{availableFields, plural, other {フィールド}}。{emptyFields}空の{emptyFields, plural, other {フィールド}}。 {metaFields}メタ{metaFields, plural, other {フィールド}}。", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "{indexPatternTitle}のみを表示", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "レイヤー{layerNumber}のみを表示", "xpack.lens.modalTitle.title.clear": "{layerType}レイヤーをクリアしますか?", @@ -17541,7 +17540,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "横軸の構成がありません。", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "横軸がありません。", "xpack.lens.indexPattern.advancedSettings": "高度な設定", - "xpack.lens.indexPattern.allFieldsLabel": "すべてのフィールド", "xpack.lens.indexPattern.allFieldsLabelHelp": "使用可能なフィールドをワークスペースまでドラッグし、ビジュアライゼーションを作成します。使用可能なフィールドを変更するには、別のデータビューを選択するか、クエリを編集するか、別の時間範囲を使用します。一部のフィールドタイプは、完全なテキストおよびグラフィックフィールドを含む Lens では、ビジュアライゼーションできません。", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "使用可能なフィールドには、フィルターと一致する最初の 500 件のドキュメントのデータがあります。すべてのフィールドを表示するには、空のフィールドを展開します。全文、地理、フラット化、オブジェクトフィールドでビジュアライゼーションを作成できません。", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "ドキュメントをご覧ください", @@ -17588,12 +17586,7 @@ "xpack.lens.indexPattern.differences.signature": "メトリック:数値", "xpack.lens.indexPattern.emptyDimensionButton": "空のディメンション", "xpack.lens.indexPattern.emptyFieldsLabel": "空のフィールド", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空のフィールドには、フィルターに基づく最初の 500 件のドキュメントの値が含まれていませんでした。", "xpack.lens.indexPattern.enableAccuracyMode": "精度モードを有効にする", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "存在の取り込みに失敗しました", - "xpack.lens.indexPattern.existenceErrorLabel": "フィールド情報を読み込めません", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "存在の取り込みがタイムアウトしました", - "xpack.lens.indexPattern.existenceTimeoutLabel": "フィールド情報に時間がかかりすぎました", "xpack.lens.indexPattern.fieldItemTooltip": "可視化するには、ドラッグアンドドロップします。", "xpack.lens.indexPattern.fieldPlaceholder": "フィールド", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "このフィールドにはデータがありませんが、ドラッグアンドドロップで可視化できます。", @@ -17771,16 +17764,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "最初にこのフィールドでグループ化", "xpack.lens.indexPatterns.clearFiltersLabel": "名前とタイプフィルターを消去", "xpack.lens.indexPatterns.filterByNameLabel": "検索フィールド名", - "xpack.lens.indexPatterns.noAvailableDataLabel": "データを含むフィールドはありません。", - "xpack.lens.indexPatterns.noDataLabel": "フィールドがありません。", - "xpack.lens.indexPatterns.noEmptyDataLabel": "空のフィールドがありません。", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "時間範囲を拡張中", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "別のフィールドフィルターを使用", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "グローバルフィルターを変更", - "xpack.lens.indexPatterns.noFields.tryText": "試行対象:", - "xpack.lens.indexPatterns.noFieldsLabel": "このデータビューにはフィールドがありません。", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "選択したフィルターと一致するフィールドはありません。", - "xpack.lens.indexPatterns.noMetaDataLabel": "メタフィールドがありません。", "xpack.lens.label.gauge.labelMajor.header": "タイトル", "xpack.lens.label.gauge.labelMinor.header": "サブタイトル", "xpack.lens.label.header": "ラベル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index ce903ab668bef..c595b967b9e54 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17256,7 +17256,6 @@ "xpack.lens.indexPattern.timeShiftSmallWarning": "{label} 使用的时间偏移 {columnTimeShift} 小于 Date Histogram 时间间隔 {interval} 。要防止数据不匹配,请使用 {interval} 的倍数作为时间偏移。", "xpack.lens.indexPattern.uniqueLabel": "{label} [{num}]", "xpack.lens.indexPattern.valueCountOf": "{name} 的计数", - "xpack.lens.indexPatterns.fieldSearchLiveRegion": "{availableFields} 个可用{availableFields, plural, other {字段}}。{emptyFields} 个空{emptyFields, plural, other {字段}}。{metaFields} 个元{metaFields, plural,other {字段}}。", "xpack.lens.indexPatternSuggestion.removeLayerLabel": "仅显示 {indexPatternTitle}", "xpack.lens.indexPatternSuggestion.removeLayerPositionLabel": "仅显示图层 {layerNumber}", "xpack.lens.modalTitle.title.clear": "清除 {layerType} 图层?", @@ -17566,7 +17565,6 @@ "xpack.lens.heatmapVisualization.missingXAccessorLongMessage": "水平轴配置缺失。", "xpack.lens.heatmapVisualization.missingXAccessorShortMessage": "缺失水平轴。", "xpack.lens.indexPattern.advancedSettings": "高级", - "xpack.lens.indexPattern.allFieldsLabel": "所有字段", "xpack.lens.indexPattern.allFieldsLabelHelp": "将可用字段拖放到工作区并创建可视化。要更改可用字段,请选择不同数据视图,编辑您的查询或使用不同时间范围。一些字段类型无法在 Lens 中可视化,包括全文本字段和地理字段。", "xpack.lens.indexPattern.allFieldsSamplingLabelHelp": "可用字段包含与您的筛选匹配的前 500 个文档中的数据。要查看所有字段,请展开空字段。无法使用全文本、地理、扁平和对象字段创建可视化。", "xpack.lens.indexPattern.ascendingCountPrecisionErrorWarning.link": "访问文档", @@ -17613,12 +17611,7 @@ "xpack.lens.indexPattern.differences.signature": "指标:数字", "xpack.lens.indexPattern.emptyDimensionButton": "空维度", "xpack.lens.indexPattern.emptyFieldsLabel": "空字段", - "xpack.lens.indexPattern.emptyFieldsLabelHelp": "空字段在基于您的筛选的前 500 个文档中不包含任何值。", "xpack.lens.indexPattern.enableAccuracyMode": "启用准确性模式", - "xpack.lens.indexPattern.existenceErrorAriaLabel": "现有内容提取失败", - "xpack.lens.indexPattern.existenceErrorLabel": "无法加载字段信息", - "xpack.lens.indexPattern.existenceTimeoutAriaLabel": "现有内容提取超时", - "xpack.lens.indexPattern.existenceTimeoutLabel": "字段信息花费时间过久", "xpack.lens.indexPattern.fieldItemTooltip": "拖放以可视化。", "xpack.lens.indexPattern.fieldPlaceholder": "字段", "xpack.lens.indexPattern.fieldStatsButtonEmptyLabel": "此字段不包含任何数据,但您仍然可以拖放以进行可视化。", @@ -17796,16 +17789,6 @@ "xpack.lens.indexPattern.useAsTopLevelAgg": "先按此字段分组", "xpack.lens.indexPatterns.clearFiltersLabel": "清除名称和类型筛选", "xpack.lens.indexPatterns.filterByNameLabel": "搜索字段名称", - "xpack.lens.indexPatterns.noAvailableDataLabel": "没有包含数据的可用字段。", - "xpack.lens.indexPatterns.noDataLabel": "无字段。", - "xpack.lens.indexPatterns.noEmptyDataLabel": "无空字段。", - "xpack.lens.indexPatterns.noFields.extendTimeBullet": "延伸时间范围", - "xpack.lens.indexPatterns.noFields.fieldTypeFilterBullet": "使用不同的字段筛选", - "xpack.lens.indexPatterns.noFields.globalFiltersBullet": "更改全局筛选", - "xpack.lens.indexPatterns.noFields.tryText": "尝试:", - "xpack.lens.indexPatterns.noFieldsLabel": "在此数据视图中不存在任何字段。", - "xpack.lens.indexPatterns.noFilteredFieldsLabel": "没有字段匹配选定筛选。", - "xpack.lens.indexPatterns.noMetaDataLabel": "无元字段。", "xpack.lens.label.gauge.labelMajor.header": "标题", "xpack.lens.label.gauge.labelMinor.header": "子标题", "xpack.lens.label.header": "标签",