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