[Discover][UnifiedFieldList] Integrate unified field list sections into Discover (#144412)

Closes https://github.com/elastic/kibana/issues/135678

## Summary

This PR continues the work started in
https://github.com/elastic/kibana/pull/142758 to bring field list
grouping from Lens into Discover.

- [x] Integrate new components and hooks into Discover page
- [x] Refactor fields grouping logic
- [x] Render Popular fields under a new separate section
- [x] Remove "Hide empty fields" switch
- [x] Adjust filtering logic
- [x] Refactor fields existence logic in Discover
- [x] Add "Unmapped fields" section
- [x] Highlight the matching term when searching for a field 
- [x] Show field icons when in SQL mode
- [x] Add tooltips to field list section headings
- [x] Add tests, clean up

<img width="340" alt="Screenshot 2022-11-15 at 15 39 27"
src="https://user-images.githubusercontent.com/1415710/201947349-726ffc3a-a17f-411b-be92-81d97879765a.png">

For testing on Discover page:
Please check different use cases and toggling Advanced Settings:
- regular vs ad-hoc data views
- data views with and without a time field
- data views with unmapped and empty fields
- data views with a lot of fields
- data views with some fields being filtered out via data view
configuration
- updating query, filters, and time range
- regular and SQL mode
- searching by a field name in the sidebar
- applying a field filter in the sidebar
- adding, editing, and removing a field
- Field Statistics table when some columns are selected or no columns
are selected
- multifields in the field popover should work as before (icon should
change from "+" to "x" when subfield is selected as a column)
- `discover:searchOnPageLoad` should not show fields if turned off
- `discover:searchFieldsFromSource` should show multifields right in the
fields list if enabled
- `discover:enableSql` should show Selected and Available fields only
when enabled
- `discover:showLegacyFieldTopValues` should show old (green) field
stats in its popover
- `doc_table:legacy`

On Lens page:
- scroll position should reset when data view is switched or when
searching by a field name
- regular and SQL mode

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

Co-authored-by: Michael Marcialis <michael@marcial.is>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2022-12-01 15:02:04 +01:00 committed by GitHub
parent 08805d0d69
commit 66718fc2c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 3203 additions and 1681 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -11,22 +11,27 @@ import { dataViewMock } from './data_view';
import { dataViewComplexMock } from './data_view_complex';
import { dataViewWithTimefieldMock } from './data_view_with_timefield';
export const dataViewsMock = {
getCache: async () => {
return [dataViewMock];
},
get: async (id: string) => {
if (id === 'the-data-view-id') {
return Promise.resolve(dataViewMock);
} else if (id === 'invalid-data-view-id') {
return Promise.reject('Invald');
}
},
updateSavedObject: jest.fn(),
getIdsWithTitle: jest.fn(() => {
return Promise.resolve([dataViewMock, dataViewComplexMock, dataViewWithTimefieldMock]);
}),
createFilter: jest.fn(),
create: jest.fn(),
clearInstanceCache: jest.fn(),
} as unknown as jest.Mocked<DataViewsContract>;
export function createDiscoverDataViewsMock() {
return {
getCache: async () => {
return [dataViewMock];
},
get: async (id: string) => {
if (id === 'the-data-view-id') {
return Promise.resolve(dataViewMock);
} else if (id === 'invalid-data-view-id') {
return Promise.reject('Invald');
}
},
updateSavedObject: jest.fn(),
getIdsWithTitle: jest.fn(() => {
return Promise.resolve([dataViewMock, dataViewComplexMock, dataViewWithTimefieldMock]);
}),
createFilter: jest.fn(),
create: jest.fn(),
clearInstanceCache: jest.fn(),
getFieldsForIndexPattern: jest.fn((dataView) => dataView.fields),
} as unknown as jest.Mocked<DataViewsContract>;
}
export const dataViewsMock = createDiscoverDataViewsMock();

View file

@ -5,6 +5,7 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Observable, of } from 'rxjs';
import { EUI_CHARTS_THEME_LIGHT } from '@elastic/eui/dist/eui_charts_theme';
import { DiscoverServices } from '../build_services';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
@ -20,117 +21,137 @@ import {
SORT_DEFAULT_ORDER_SETTING,
HIDE_ANNOUNCEMENTS,
} from '../../common';
import { UI_SETTINGS } from '@kbn/data-plugin/public';
import { UI_SETTINGS, calculateBounds } from '@kbn/data-plugin/public';
import { TopNavMenu } from '@kbn/navigation-plugin/public';
import { FORMATS_UI_SETTINGS } from '@kbn/field-formats-plugin/common';
import { LocalStorageMock } from './local_storage_mock';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { dataViewsMock } from './data_views';
import { Observable, of } from 'rxjs';
const dataPlugin = dataPluginMock.createStartContract();
const expressionsPlugin = expressionsPluginMock.createStartContract();
import { LocalStorageMock } from './local_storage_mock';
import { createDiscoverDataViewsMock } from './data_views';
dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable<void>);
export function createDiscoverServicesMock(): DiscoverServices {
const dataPlugin = dataPluginMock.createStartContract();
const expressionsPlugin = expressionsPluginMock.createStartContract();
export const discoverServiceMock = {
core: coreMock.createStart(),
chrome: chromeServiceMock.createStartContract(),
history: () => ({
location: {
search: '',
},
listen: jest.fn(),
}),
data: dataPlugin,
docLinks: docLinksServiceMock.createStartContract(),
capabilities: {
visualize: {
show: true,
},
discover: {
save: false,
},
advancedSettings: {
save: true,
},
},
fieldFormats: fieldFormatsMock,
filterManager: dataPlugin.query.filterManager,
inspector: {
open: jest.fn(),
},
uiSettings: {
get: jest.fn((key: string) => {
if (key === 'fields:popularLimit') {
return 5;
} else if (key === DEFAULT_COLUMNS_SETTING) {
return ['default_column'];
} else if (key === UI_SETTINGS.META_FIELDS) {
return [];
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
return false;
} else if (key === CONTEXT_STEP_SETTING) {
return 5;
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
return 'desc';
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
return false;
} else if (key === SAMPLE_SIZE_SETTING) {
return 250;
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
return 150;
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
return 50;
} else if (key === HIDE_ANNOUNCEMENTS) {
return false;
}
dataPlugin.query.filterManager.getFilters = jest.fn(() => []);
dataPlugin.query.filterManager.getUpdates$ = jest.fn(() => of({}) as unknown as Observable<void>);
dataPlugin.query.timefilter.timefilter.createFilter = jest.fn();
dataPlugin.query.timefilter.timefilter.getAbsoluteTime = jest.fn(() => ({
from: '2021-08-31T22:00:00.000Z',
to: '2022-09-01T09:16:29.553Z',
}));
dataPlugin.query.timefilter.timefilter.getTime = jest.fn(() => {
return { from: 'now-15m', to: 'now' };
});
dataPlugin.query.timefilter.timefilter.calculateBounds = jest.fn(calculateBounds);
dataPlugin.query.getState = jest.fn(() => ({
query: { query: '', language: 'lucene' },
filters: [],
}));
dataPlugin.dataViews = createDiscoverDataViewsMock();
return {
core: coreMock.createStart(),
charts: chartPluginMock.createSetupContract(),
chrome: chromeServiceMock.createStartContract(),
history: () => ({
location: {
search: '',
},
listen: jest.fn(),
}),
isDefault: (key: string) => {
return true;
data: dataPlugin,
docLinks: docLinksServiceMock.createStartContract(),
capabilities: {
visualize: {
show: true,
},
discover: {
save: false,
},
advancedSettings: {
save: true,
},
},
},
http: {
basePath: '/',
},
dataViewEditor: {
userPermissions: {
editDataView: () => true,
fieldFormats: fieldFormatsMock,
filterManager: dataPlugin.query.filterManager,
inspector: {
open: jest.fn(),
},
},
dataViewFieldEditor: {
openEditor: jest.fn(),
userPermissions: {
editIndexPattern: jest.fn(),
uiSettings: {
get: jest.fn((key: string) => {
if (key === 'fields:popularLimit') {
return 5;
} else if (key === DEFAULT_COLUMNS_SETTING) {
return ['default_column'];
} else if (key === UI_SETTINGS.META_FIELDS) {
return [];
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
return false;
} else if (key === CONTEXT_STEP_SETTING) {
return 5;
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
return 'desc';
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
return false;
} else if (key === SAMPLE_SIZE_SETTING) {
return 250;
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
return 150;
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
return 50;
} else if (key === HIDE_ANNOUNCEMENTS) {
return false;
}
}),
isDefault: (key: string) => {
return true;
},
},
},
navigation: {
ui: { TopNavMenu, AggregateQueryTopNavMenu: TopNavMenu },
},
metadata: {
branch: 'test',
},
theme: {
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
},
storage: new LocalStorageMock({}) as unknown as Storage,
addBasePath: jest.fn(),
toastNotifications: {
addInfo: jest.fn(),
addWarning: jest.fn(),
addDanger: jest.fn(),
addSuccess: jest.fn(),
},
expressions: expressionsPlugin,
savedObjectsTagging: {},
dataViews: dataViewsMock,
timefilter: { createFilter: jest.fn() },
locator: {
useUrl: jest.fn(() => ''),
navigate: jest.fn(),
getUrl: jest.fn(() => Promise.resolve('')),
},
contextLocator: { getRedirectUrl: jest.fn(() => '') },
singleDocLocator: { getRedirectUrl: jest.fn(() => '') },
} as unknown as DiscoverServices;
http: {
basePath: '/',
},
dataViewEditor: {
userPermissions: {
editDataView: () => true,
},
},
dataViewFieldEditor: {
openEditor: jest.fn(),
userPermissions: {
editIndexPattern: jest.fn(),
},
},
navigation: {
ui: { TopNavMenu, AggregateQueryTopNavMenu: TopNavMenu },
},
metadata: {
branch: 'test',
},
theme: {
useChartsTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
useChartsBaseTheme: jest.fn(() => EUI_CHARTS_THEME_LIGHT.theme),
},
storage: new LocalStorageMock({}) as unknown as Storage,
addBasePath: jest.fn(),
toastNotifications: {
addInfo: jest.fn(),
addWarning: jest.fn(),
addDanger: jest.fn(),
addSuccess: jest.fn(),
},
expressions: expressionsPlugin,
savedObjectsTagging: {},
dataViews: dataPlugin.dataViews,
timefilter: dataPlugin.query.timefilter.timefilter,
locator: {
useUrl: jest.fn(() => ''),
navigate: jest.fn(),
getUrl: jest.fn(() => Promise.resolve('')),
},
contextLocator: { getRedirectUrl: jest.fn(() => '') },
singleDocLocator: { getRedirectUrl: jest.fn(() => '') },
} as unknown as DiscoverServices;
}
export const discoverServiceMock = createDiscoverServicesMock();

View file

@ -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

View file

@ -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];

View file

@ -245,7 +245,7 @@ export function DiscoverLayout({
history={history}
/>
<EuiFlexGroup className="dscPageBody__contents" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} className="dscPageBody__sidebar">
<SidebarMemoized
columns={columns}
documents$={savedSearchData$.documents$}

View file

@ -21,7 +21,7 @@ import {
DataTotalHits$,
RecordRawType,
} from '../../hooks/use_saved_search';
import { discoverServiceMock } from '../../../../__mocks__/services';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
@ -110,10 +110,7 @@ const mountComponent = async ({
savedSearch?: SavedSearch;
resetSavedSearch?: () => void;
} = {}) => {
let services = discoverServiceMock;
services.data.query.timefilter.timefilter.getAbsoluteTime = () => {
return { from: '2020-05-14T11:05:13.590', to: '2020-05-14T11:20:13.590' };
};
let services = createDiscoverServicesMock();
if (storage) {
services = { ...services, storage };

View file

@ -1,94 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { storiesOf } from '@storybook/react';
import React from 'react';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import { DataViewField } from '@kbn/data-views-plugin/public';
import { DataView } from '@kbn/data-views-plugin/public';
import { DiscoverFieldDetails } from '../discover_field_details';
import { fieldSpecMap } from './fields';
import { numericField as field } from './fields';
import { Bucket } from '../types';
const buckets = [
{ count: 1, display: 'Stewart', percent: 50.0, value: 'Stewart' },
{ count: 1, display: 'Perry', percent: 50.0, value: 'Perry' },
] as Bucket[];
const details = { buckets, error: '', exists: 1, total: 2, columns: [] };
const fieldFormatInstanceType = {};
const defaultMap = {
[KBN_FIELD_TYPES.NUMBER]: { id: KBN_FIELD_TYPES.NUMBER, params: {} },
};
const fieldFormat = {
getByFieldType: (fieldType: KBN_FIELD_TYPES) => {
return [fieldFormatInstanceType];
},
getDefaultConfig: () => {
return defaultMap.number;
},
defaultMap,
};
const scriptedField = new DataViewField({
name: 'machine.os',
type: 'string',
esTypes: ['long'],
count: 10,
scripted: true,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});
const dataView = new DataView({
spec: {
id: 'logstash-*',
fields: fieldSpecMap,
title: 'logstash-*',
timeFieldName: '@timestamp',
},
metaFields: ['_id', '_type', '_source'],
shortDotsEnable: false,
// @ts-expect-error
fieldFormats: fieldFormat,
});
storiesOf('components/sidebar/DiscoverFieldDetails', module)
.add('default', () => (
<div style={{ width: '50%' }}>
<DiscoverFieldDetails
field={field}
dataView={dataView}
details={details}
onAddFilter={() => {
alert('On add filter clicked');
}}
/>
</div>
))
.add('scripted', () => (
<div style={{ width: '50%' }}>
<DiscoverFieldDetails
field={scriptedField}
dataView={dataView}
details={details}
onAddFilter={() => {}}
/>
</div>
))
.add('error', () => (
<DiscoverFieldDetails
field={field}
dataView={dataView}
details={{ buckets: [], error: 'An error occurred', exists: 1, total: 2 }}
onAddFilter={() => {}}
/>
));

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataViewField, FieldSpec } from '@kbn/data-views-plugin/public';
export const fieldSpecMap: Record<string, FieldSpec> = {
'machine.os': {
name: 'machine.os',
esTypes: ['text'],
type: 'string',
aggregatable: false,
searchable: false,
},
'machine.os.raw': {
name: 'machine.os.raw',
type: 'string',
esTypes: ['keyword'],
aggregatable: true,
searchable: true,
},
'not.filterable': {
name: 'not.filterable',
type: 'string',
esTypes: ['text'],
aggregatable: true,
searchable: false,
},
bytes: {
name: 'bytes',
type: 'number',
esTypes: ['long'],
aggregatable: true,
searchable: true,
},
};
export const numericField = new DataViewField({
name: 'bytes',
type: 'number',
esTypes: ['long'],
count: 10,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
});

View file

@ -12,7 +12,11 @@ import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DiscoverFieldDetails } from './discover_field_details';
import { DataViewField } from '@kbn/data-views-plugin/public';
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { stubDataView, stubLogstashDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { BehaviorSubject } from 'rxjs';
import { FetchStatus } from '../../../../types';
import { DataDocuments$ } from '../../../hooks/use_saved_search';
import { getDataTableRecords } from '../../../../../__fixtures__/real_hits';
describe('discover sidebar field details', function () {
const onAddFilter = jest.fn();
@ -21,9 +25,14 @@ describe('discover sidebar field details', function () {
details: { buckets: [], error: '', exists: 1, total: 2, columns: [] },
onAddFilter,
};
const hits = getDataTableRecords(stubLogstashDataView);
const documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: hits,
}) as DataDocuments$;
function mountComponent(field: DataViewField) {
const compProps = { ...defaultProps, field };
const compProps = { ...defaultProps, field, documents$ };
return mountWithIntl(<DiscoverFieldDetails {...compProps} />);
}

View file

@ -6,28 +6,52 @@
* Side Public License, v 1.
*/
import React from 'react';
import { EuiText, EuiSpacer, EuiLink } from '@elastic/eui';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiText, EuiSpacer, EuiLink, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { DiscoverFieldBucket } from './discover_field_bucket';
import { Bucket, FieldDetails } from './types';
import { getDetails } from './get_details';
import { DataDocuments$ } from '../../../hooks/use_saved_search';
import { FetchStatus } from '../../../../types';
interface DiscoverFieldDetailsProps {
/**
* hits fetched from ES, displayed in the doc table
*/
documents$: DataDocuments$;
field: DataViewField;
dataView: DataView;
details: FieldDetails;
onAddFilter?: (field: DataViewField | string, value: string, type: '+' | '-') => void;
}
export function DiscoverFieldDetails({
documents$,
field,
dataView,
details,
onAddFilter,
}: DiscoverFieldDetailsProps) {
const details: FieldDetails = useMemo(() => {
const data = documents$.getValue();
const documents = data.fetchStatus === FetchStatus.COMPLETE ? data.result : undefined;
return getDetails(field, documents, dataView);
}, [field, documents$, dataView]);
if (!details?.error && !details?.buckets) {
return null;
}
return (
<>
<div data-test-subj={`discoverFieldDetails-${field.name}`}>
<EuiTitle size="xxxs">
<h5>
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
defaultMessage: 'Top 5 values',
})}
</h5>
</EuiTitle>
{details.error && <EuiText size="xs">{details.error}</EuiText>}
{!details.error && (
<>
@ -70,6 +94,6 @@ export function DiscoverFieldDetails({
</EuiText>
</>
)}
</>
</div>
);
}

View file

@ -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;

View file

@ -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({

View file

@ -9,7 +9,14 @@
import './discover_field.scss';
import React, { useState, useCallback, memo, useMemo } from 'react';
import { EuiButtonIcon, EuiToolTip, EuiTitle, EuiIcon, EuiSpacer } from '@elastic/eui';
import {
EuiButtonIcon,
EuiToolTip,
EuiTitle,
EuiIcon,
EuiSpacer,
EuiHighlight,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import classNames from 'classnames';
@ -23,12 +30,12 @@ import {
} from '@kbn/unified-field-list-plugin/public';
import { DiscoverFieldStats } from './discover_field_stats';
import { getTypeForFieldIcon } from '../../../../utils/get_type_for_field_icon';
import { DiscoverFieldDetails } from './discover_field_details';
import { FieldDetails } from './types';
import { DiscoverFieldDetails } from './deprecated_stats/discover_field_details';
import { getFieldTypeName } from '../../../../utils/get_field_type_name';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { SHOW_LEGACY_FIELD_TOP_VALUES, PLUGIN_ID } from '../../../../../common';
import { getUiActions } from '../../../../kibana_services';
import { type DataDocuments$ } from '../../hooks/use_saved_search';
function wrapOnDot(str?: string) {
// u200B is a non-width white-space character, which allows
@ -64,24 +71,31 @@ const DiscoverFieldTypeIcon: React.FC<{ field: DataViewField }> = memo(({ field
);
});
const FieldName: React.FC<{ field: DataViewField }> = memo(({ field }) => {
const title =
field.displayName !== field.name
? i18n.translate('discover.field.title', {
defaultMessage: '{fieldName} ({fieldDisplayName})',
values: {
fieldName: field.name,
fieldDisplayName: field.displayName,
},
})
: field.displayName;
const FieldName: React.FC<{ field: DataViewField; highlight?: string }> = memo(
({ field, highlight }) => {
const title =
field.displayName !== field.name
? i18n.translate('discover.field.title', {
defaultMessage: '{fieldName} ({fieldDisplayName})',
values: {
fieldName: field.name,
fieldDisplayName: field.displayName,
},
})
: field.displayName;
return (
<span data-test-subj={`field-${field.name}`} title={title} className="dscSidebarField__name">
{wrapOnDot(field.displayName)}
</span>
);
});
return (
<EuiHighlight
search={wrapOnDot(highlight)}
data-test-subj={`field-${field.name}`}
title={title}
className="dscSidebarField__name"
>
{wrapOnDot(field.displayName)}
</EuiHighlight>
);
}
);
interface ActionButtonProps {
field: DataViewField;
@ -128,6 +142,7 @@ const ActionButton: React.FC<ActionButtonProps> = memo(
} else {
return (
<EuiToolTip
key={`tooltip-${field.name}-${field.count || 0}-${isSelected}`}
delay="long"
content={i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
@ -164,11 +179,10 @@ interface MultiFieldsProps {
multiFields: NonNullable<DiscoverFieldProps['multiFields']>;
toggleDisplay: (field: DataViewField) => void;
alwaysShowActionButton: boolean;
isDocumentRecord: boolean;
}
const MultiFields: React.FC<MultiFieldsProps> = memo(
({ multiFields, toggleDisplay, alwaysShowActionButton, isDocumentRecord }) => (
({ multiFields, toggleDisplay, alwaysShowActionButton }) => (
<React.Fragment>
<EuiTitle size="xxxs">
<h5>
@ -184,7 +198,7 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
className="dscSidebarItem dscSidebarItem--multi"
isActive={false}
dataTestSubj={`field-${entry.field.name}-showDetails`}
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={entry.field} />}
fieldIcon={<DiscoverFieldTypeIcon field={entry.field} />}
fieldAction={
<ActionButton
field={entry.field}
@ -202,6 +216,10 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
);
export interface DiscoverFieldProps {
/**
* hits fetched from ES, displayed in the doc table
*/
documents$: DataDocuments$;
/**
* Determines whether add/remove button is displayed not only when focused
*/
@ -227,10 +245,6 @@ export interface DiscoverFieldProps {
* @param fieldName
*/
onRemoveField: (fieldName: string) => void;
/**
* Retrieve details data for the field
*/
getDetails: (field: DataViewField) => FieldDetails;
/**
* Determines whether the field is selected
*/
@ -264,16 +278,22 @@ export interface DiscoverFieldProps {
* Columns
*/
contextualFields: string[];
/**
* Search by field name
*/
highlight?: string;
}
function DiscoverFieldComponent({
documents$,
alwaysShowActionButton = false,
field,
highlight,
dataView,
onAddField,
onRemoveField,
onAddFilter,
getDetails,
selected,
trackUiMetric,
multiFields,
@ -345,7 +365,7 @@ function DiscoverFieldComponent({
size="s"
className="dscSidebarItem"
dataTestSubj={`field-${field.name}-showDetails`}
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={field} />}
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
fieldAction={
<ActionButton
field={field}
@ -364,9 +384,9 @@ function DiscoverFieldComponent({
size="s"
className="dscSidebarItem"
isActive={infoIsOpen}
onClick={togglePopover}
onClick={isDocumentRecord ? togglePopover : undefined}
dataTestSubj={`field-${field.name}-showDetails`}
fieldIcon={isDocumentRecord && <DiscoverFieldTypeIcon field={field} />}
fieldIcon={<DiscoverFieldTypeIcon field={field} />}
fieldAction={
<ActionButton
field={field}
@ -375,7 +395,7 @@ function DiscoverFieldComponent({
toggleDisplay={toggleDisplay}
/>
}
fieldName={<FieldName field={field} />}
fieldName={<FieldName field={field} highlight={highlight} />}
fieldInfoIcon={field.type === 'conflict' && <FieldInfoIcon />}
/>
);
@ -389,24 +409,15 @@ function DiscoverFieldComponent({
return (
<>
{showLegacyFieldStats ? (
{showLegacyFieldStats ? ( // TODO: Deprecate and remove after ~v8.7
<>
{showFieldStats && (
<>
<EuiTitle size="xxxs">
<h5>
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
defaultMessage: 'Top 5 values',
})}
</h5>
</EuiTitle>
<DiscoverFieldDetails
dataView={dataView}
field={field}
details={getDetails(field)}
onAddFilter={onAddFilter}
/>
</>
<DiscoverFieldDetails
documents$={documents$}
dataView={dataView}
field={field}
onAddFilter={onAddFilter}
/>
)}
</>
) : (
@ -425,7 +436,6 @@ function DiscoverFieldComponent({
multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
isDocumentRecord={isDocumentRecord}
/>
</>
)}

View file

@ -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', () => {

View file

@ -17,11 +17,8 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiPopoverFooter,
EuiPopoverTitle,
EuiSelect,
EuiSwitch,
EuiSwitchEvent,
EuiForm,
EuiFormRow,
EuiButtonGroup,
@ -43,7 +40,6 @@ export interface State {
searchable: string;
aggregatable: string;
type: string;
missing: boolean;
[index: string]: string | boolean;
}
@ -68,6 +64,11 @@ export interface Props {
* is text base lang mode
*/
isPlainRecord: boolean;
/**
* For a11y
*/
fieldSearchDescriptionId?: string;
}
interface FieldTypeTableItem {
@ -86,6 +87,7 @@ export function DiscoverFieldSearch({
types,
presentFieldTypes,
isPlainRecord,
fieldSearchDescriptionId,
}: Props) {
const searchPlaceholder = i18n.translate('discover.fieldChooser.searchPlaceHolder', {
defaultMessage: 'Search field names',
@ -112,7 +114,6 @@ export function DiscoverFieldSearch({
searchable: 'any',
aggregatable: 'any',
type: 'any',
missing: true,
});
const { docLinks } = useDiscoverServices();
@ -191,7 +192,7 @@ export function DiscoverFieldSearch({
};
const isFilterActive = (name: string, filterValue: string | boolean) => {
return name !== 'missing' && filterValue !== 'any';
return filterValue !== 'any';
};
const handleValueChange = (name: string, filterValue: string | boolean) => {
@ -214,11 +215,6 @@ export function DiscoverFieldSearch({
setActiveFiltersCount(activeFiltersCount + diff);
};
const handleMissingChange = (e: EuiSwitchEvent) => {
const missingValue = e.target.checked;
handleValueChange('missing', missingValue);
};
const buttonContent = (
<EuiFilterButton
aria-label={filterBtnAriaLabel}
@ -297,21 +293,6 @@ export function DiscoverFieldSearch({
);
};
const footer = () => {
return (
<EuiPopoverFooter paddingSize="s">
<EuiSwitch
label={i18n.translate('discover.fieldChooser.filter.hideEmptyFieldsLabel', {
defaultMessage: 'Hide empty fields',
})}
checked={values.missing}
onChange={handleMissingChange}
data-test-subj="missingSwitch"
/>
</EuiPopoverFooter>
);
};
const selectionPanel = (
<div className="dscFieldSearch__formWrapper">
<EuiForm data-test-subj="filterSelectionPanel">
@ -353,10 +334,11 @@ export function DiscoverFieldSearch({
<EuiFlexGroup responsive={false} gutterSize={'s'}>
<EuiFlexItem>
<EuiFieldSearch
aria-describedby={fieldSearchDescriptionId}
aria-label={searchPlaceholder}
data-test-subj="fieldFilterSearchInput"
fullWidth
onChange={(event) => onChange('name', event.currentTarget.value)}
onChange={(event) => onChange('name', event.target.value)}
placeholder={searchPlaceholder}
value={value}
/>
@ -384,7 +366,6 @@ export function DiscoverFieldSearch({
})}
</EuiPopoverTitle>
{selectionPanel}
{footer()}
</EuiPopover>
<EuiPopover
anchorPosition="rightUp"

View file

@ -11,6 +11,7 @@ import {
FieldStats,
FieldStatsProps,
useQuerySubscriber,
hasQuerySubscriberData,
} from '@kbn/unified-field-list-plugin/public';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
@ -25,7 +26,6 @@ export interface DiscoverFieldStatsProps {
export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
({ field, dataView, multiFields, onAddFilter }) => {
const services = useDiscoverServices();
const dateRange = services.data?.query?.timefilter.timefilter.getAbsoluteTime();
const querySubscriberResult = useQuerySubscriber({
data: services.data,
});
@ -38,7 +38,7 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
[field, multiFields]
);
if (!dateRange || !querySubscriberResult.query || !querySubscriberResult.filters) {
if (!hasQuerySubscriberData(querySubscriberResult)) {
return null;
}
@ -47,8 +47,8 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
services={services}
query={querySubscriberResult.query}
filters={querySubscriberResult.filters}
fromDate={dateRange.from}
toDate={dateRange.to}
fromDate={querySubscriberResult.fromDate}
toDate={querySubscriberResult.toDate}
dataViewOrDataViewId={dataView}
field={fieldForStats}
data-test-subj="dscFieldStats"

View file

@ -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,

View file

@ -6,30 +6,38 @@
* Side Public License, v 1.
*/
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { findTestSubject } from '@elastic/eui/lib/test';
import { Action } from '@kbn/ui-actions-plugin/public';
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
import { DiscoverSidebarProps } from './discover_sidebar';
import {
DiscoverSidebarComponent as DiscoverSidebar,
DiscoverSidebarProps,
} from './discover_sidebar';
import { DataViewListItem } from '@kbn/data-views-plugin/public';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { getDefaultFieldFilter } from './lib/field_filter';
import { DiscoverSidebarComponent as DiscoverSidebar } from './discover_sidebar';
import { discoverServiceMock as mockDiscoverServices } from '../../../../__mocks__/services';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { FetchStatus } from '../../../types';
import { AvailableFields$ } from '../../hooks/use_saved_search';
import { AvailableFields$, DataDocuments$ } from '../../hooks/use_saved_search';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
import * as ExistingFieldsHookApi from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
import { ExistenceFetchStatus } from '@kbn/unified-field-list-plugin/public';
import { getDataViewFieldList } from './lib/get_data_view_field_list';
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
() => Promise.resolve([])
);
jest.spyOn(ExistingFieldsHookApi, 'useExistingFieldsReader');
jest.mock('../../../../kibana_services', () => ({
getUiActions: () => ({
getTriggerCompatibleActions: mockGetActions,
@ -41,12 +49,6 @@ function getCompProps(): DiscoverSidebarProps {
dataView.toSpec = jest.fn(() => ({}));
const hits = getDataTableRecords(dataView);
const dataViewList = [
{ id: '0', title: 'b' } as DataViewListItem,
{ id: '1', title: 'a' } as DataViewListItem,
{ id: '2', title: 'c' } as DataViewListItem,
];
const fieldCounts: Record<string, number> = {};
for (const hit of hits) {
@ -54,16 +56,36 @@ function getCompProps(): DiscoverSidebarProps {
fieldCounts[key] = (fieldCounts[key] || 0) + 1;
}
}
const allFields = getDataViewFieldList(dataView, fieldCounts, false);
(ExistingFieldsHookApi.useExistingFieldsReader as jest.Mock).mockClear();
(ExistingFieldsHookApi.useExistingFieldsReader as jest.Mock).mockImplementation(() => ({
hasFieldData: (dataViewId: string, fieldName: string) => {
return dataViewId === dataView.id && Object.keys(fieldCounts).includes(fieldName);
},
getFieldsExistenceStatus: (dataViewId: string) => {
return dataViewId === dataView.id
? ExistenceFetchStatus.succeeded
: ExistenceFetchStatus.unknown;
},
isFieldsExistenceInfoUnavailable: (dataViewId: string) => dataViewId !== dataView.id,
}));
const availableFields$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
fields: [] as string[],
}) as AvailableFields$;
const documents$ = new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
result: hits,
}) as DataDocuments$;
return {
columns: ['extension'],
fieldCounts,
documents: hits,
dataViewList,
allFields,
dataViewList: [dataView as DataViewListItem],
onChangeDataView: jest.fn(),
onAddFilter: jest.fn(),
onAddField: jest.fn(),
@ -77,37 +99,38 @@ function getCompProps(): DiscoverSidebarProps {
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
createNewDataView: jest.fn(),
onDataViewCreated: jest.fn(),
documents$,
availableFields$,
useNewFieldsApi: true,
showFieldList: true,
isAffectedByGlobalFilter: false,
};
}
function getAppStateContainer() {
function getAppStateContainer({ query }: { query?: Query | AggregateQuery }) {
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
appStateContainer.set({
query: { query: '', language: 'lucene' },
query: query ?? { query: '', language: 'lucene' },
filters: [],
});
return appStateContainer;
}
describe('discover sidebar', function () {
let props: DiscoverSidebarProps;
async function mountComponent(
props: DiscoverSidebarProps,
appStateParams: { query?: Query | AggregateQuery } = {}
): Promise<ReactWrapper<DiscoverSidebarProps>> {
let comp: ReactWrapper<DiscoverSidebarProps>;
const mockedServices = createDiscoverServicesMock();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () => props.dataViewList);
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
return [props.selectedDataView].find((d) => d!.id === id);
});
beforeAll(async () => {
props = getCompProps();
mockDiscoverServices.data.dataViews.getIdsWithTitle = jest
.fn()
.mockReturnValue(props.dataViewList);
mockDiscoverServices.data.dataViews.get = jest.fn().mockImplementation((id) => {
const dataView = props.dataViewList.find((d) => d.id === id);
return { ...dataView, isPersisted: () => true };
});
await act(async () => {
comp = await mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverAppStateProvider value={getAppStateContainer()}>
<KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
<DiscoverSidebar {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
@ -117,24 +140,50 @@ describe('discover sidebar', function () {
await comp.update();
});
it('should have Selected Fields and Available Fields with Popular Fields sections', function () {
const popular = findTestSubject(comp, 'fieldList-popular');
const selected = findTestSubject(comp, 'fieldList-selected');
const unpopular = findTestSubject(comp, 'fieldList-unpopular');
expect(popular.children().length).toBe(1);
expect(unpopular.children().length).toBe(6);
expect(selected.children().length).toBe(1);
await comp!.update();
return comp!;
}
describe('discover sidebar', function () {
let props: DiscoverSidebarProps;
beforeEach(async () => {
props = getCompProps();
});
it('should allow selecting fields', function () {
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
it('should hide field list', async function () {
const comp = await mountComponent({
...props,
showFieldList: false,
});
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(false);
});
it('should have Selected Fields and Available Fields with Popular Fields sections', async function () {
const comp = await mountComponent(props);
const popularFieldsCount = findTestSubject(comp, 'fieldListGroupedPopularFields-count');
const selectedFieldsCount = findTestSubject(comp, 'fieldListGroupedSelectedFields-count');
const availableFieldsCount = findTestSubject(comp, 'fieldListGroupedAvailableFields-count');
expect(popularFieldsCount.text()).toBe('4');
expect(availableFieldsCount.text()).toBe('3');
expect(selectedFieldsCount.text()).toBe('1');
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(true);
});
it('should allow selecting fields', async function () {
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
findTestSubject(availableFields, 'fieldToggle-bytes').simulate('click');
expect(props.onAddField).toHaveBeenCalledWith('bytes');
});
it('should allow deselecting fields', function () {
findTestSubject(comp, 'fieldToggle-extension').simulate('click');
it('should allow deselecting fields', async function () {
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
findTestSubject(availableFields, 'fieldToggle-extension').simulate('click');
expect(props.onRemoveField).toHaveBeenCalledWith('extension');
});
it('should render "Add a field" button', () => {
it('should render "Add a field" button', async () => {
const comp = await mountComponent(props);
const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
expect(addFieldButton.length).toBe(1);
addFieldButton.simulate('click');
@ -142,8 +191,11 @@ describe('discover sidebar', function () {
});
it('should render "Edit field" button', async () => {
findTestSubject(comp, 'field-bytes').simulate('click');
await new Promise((resolve) => setTimeout(resolve, 0));
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
await act(async () => {
findTestSubject(availableFields, 'field-bytes').simulate('click');
});
await comp.update();
const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes');
expect(editFieldButton.length).toBe(1);
@ -151,29 +203,27 @@ describe('discover sidebar', function () {
expect(props.editField).toHaveBeenCalledWith('bytes');
});
it('should not render Add/Edit field buttons in viewer mode', () => {
const compInViewerMode = mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverAppStateProvider value={getAppStateContainer()}>
<DiscoverSidebar {...props} editField={undefined} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
it('should not render Add/Edit field buttons in viewer mode', async () => {
const compInViewerMode = await mountComponent({
...getCompProps(),
editField: undefined,
});
const addFieldButton = findTestSubject(compInViewerMode, 'dataView-add-field_btn');
expect(addFieldButton.length).toBe(0);
findTestSubject(comp, 'field-bytes').simulate('click');
const availableFields = findTestSubject(compInViewerMode, 'fieldListGroupedAvailableFields');
await act(async () => {
findTestSubject(availableFields, 'field-bytes').simulate('click');
});
const editFieldButton = findTestSubject(compInViewerMode, 'discoverFieldListPanelEdit-bytes');
expect(editFieldButton.length).toBe(0);
});
it('should render buttons in data view picker correctly', async () => {
const compWithPicker = mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverAppStateProvider value={getAppStateContainer()}>
<DiscoverSidebar {...props} showDataViewPicker />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
const propsWithPicker = {
...getCompProps(),
showDataViewPicker: true,
};
const compWithPicker = await mountComponent(propsWithPicker);
// open data view picker
findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click');
expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1);
@ -184,27 +234,21 @@ describe('discover sidebar', function () {
);
expect(addFieldButtonInDataViewPicker.length).toBe(1);
addFieldButtonInDataViewPicker.simulate('click');
expect(props.editField).toHaveBeenCalledWith();
expect(propsWithPicker.editField).toHaveBeenCalledWith();
// click "Create a data view"
const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new');
expect(createDataViewButton.length).toBe(1);
createDataViewButton.simulate('click');
expect(props.createNewDataView).toHaveBeenCalled();
expect(propsWithPicker.createNewDataView).toHaveBeenCalled();
});
it('should not render buttons in data view picker when in viewer mode', async () => {
const compWithPickerInViewerMode = mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverAppStateProvider value={getAppStateContainer()}>
<DiscoverSidebar
{...props}
showDataViewPicker
editField={undefined}
createNewDataView={undefined}
/>
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
const compWithPickerInViewerMode = await mountComponent({
...getCompProps(),
showDataViewPicker: true,
editField: undefined,
createNewDataView: undefined,
});
// open data view picker
findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click');
expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1);
@ -218,14 +262,10 @@ describe('discover sidebar', function () {
expect(createDataViewButton.length).toBe(0);
});
it('should render the Visualize in Lens button in text based languages mode', () => {
const compInViewerMode = mountWithIntl(
<KibanaContextProvider services={mockDiscoverServices}>
<DiscoverAppStateProvider value={getAppStateContainer()}>
<DiscoverSidebar {...props} onAddFilter={undefined} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
it('should render the Visualize in Lens button in text based languages mode', async () => {
const compInViewerMode = await mountComponent(getCompProps(), {
query: { sql: 'SELECT * FROM test' },
});
const visualizeField = findTestSubject(compInViewerMode, 'textBased-visualize');
expect(visualizeField.length).toBe(1);
});

View file

@ -7,49 +7,48 @@
*/
import './discover_sidebar.scss';
import { throttle } from 'lodash';
import React, { useCallback, useEffect, useState, useMemo, useRef, memo } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiAccordion,
EuiFlexItem,
EuiFlexGroup,
EuiText,
EuiTitle,
EuiSpacer,
EuiNotificationBadge,
EuiPageSideBar_Deprecated as EuiPageSideBar,
useResizeObserver,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiPageSideBar_Deprecated as EuiPageSideBar,
htmlIdGenerator,
} from '@elastic/eui';
import { isOfAggregateQueryType } from '@kbn/es-query';
import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect';
import { isEqual } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import { triggerVisualizeActionsTextBasedLanguages } from '@kbn/unified-field-list-plugin/public';
import { type DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import {
FieldListGrouped,
FieldListGroupedProps,
FieldsGroupNames,
GroupedFieldsParams,
triggerVisualizeActionsTextBasedLanguages,
useExistingFieldsReader,
useGroupedFields,
} from '@kbn/unified-field-list-plugin/public';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverField } from './discover_field';
import { DiscoverFieldSearch } from './discover_field_search';
import { FIELDS_LIMIT_SETTING, PLUGIN_ID } from '../../../../../common';
import { groupFields } from './lib/group_fields';
import { getDetails } from './lib/get_details';
import { FieldFilterState, getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter';
import { getDataViewFieldList } from './lib/get_data_view_field_list';
import {
getSelectedFields,
shouldShowField,
type SelectedFieldsResult,
INITIAL_SELECTED_FIELDS_RESULT,
} from './lib/group_fields';
import { doesFieldMatchFilters, FieldFilterState, setFieldFilterProp } from './lib/field_filter';
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
import type { DataTableRecord } from '../../../../types';
import { getUiActions } from '../../../../kibana_services';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { RecordRawType } from '../../hooks/use_saved_search';
/**
* Default number of available fields displayed and added on scroll
*/
const FIELDS_PER_PAGE = 50;
const fieldSearchDescriptionId = htmlIdGenerator()();
export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProps, 'documents$'> {
export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
/**
* Current state of the field filter, filtering fields by name, type, ...
*/
@ -84,27 +83,37 @@ export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProp
createNewDataView?: () => void;
/**
* a statistics of the distribution of fields in the given hits
* All fields: fields from data view and unmapped fields
*/
fieldCounts?: Record<string, number>;
/**
* hits fetched from ES, displayed in the doc table
*/
documents?: DataTableRecord[];
allFields: DataViewField[] | null;
/**
* Discover view mode
*/
viewMode: VIEW_MODE;
/**
* Show data view picker (for mobile view)
*/
showDataViewPicker?: boolean;
/**
* Whether to render the field list or not (we don't show it unless documents are loaded)
*/
showFieldList?: boolean;
/**
* Whether filters are applied
*/
isAffectedByGlobalFilter: boolean;
}
export function DiscoverSidebarComponent({
alwaysShowActionButtons = false,
columns,
fieldCounts,
fieldFilter,
documents,
documents$,
allFields,
onAddField,
onAddFilter,
onRemoveField,
@ -120,108 +129,28 @@ export function DiscoverSidebarComponent({
viewMode,
createNewDataView,
showDataViewPicker,
showFieldList,
isAffectedByGlobalFilter,
}: DiscoverSidebarProps) {
const { uiSettings, dataViewFieldEditor } = useDiscoverServices();
const [fields, setFields] = useState<DataViewField[] | null>(null);
const [scrollContainer, setScrollContainer] = useState<Element | null>(null);
const [fieldsToRender, setFieldsToRender] = useState(FIELDS_PER_PAGE);
const [fieldsPerPage, setFieldsPerPage] = useState(FIELDS_PER_PAGE);
const availableFieldsContainer = useRef<HTMLUListElement | null>(null);
const isPlainRecord = !onAddFilter;
const { uiSettings, dataViewFieldEditor, dataViews } = useDiscoverServices();
const isPlainRecord = useAppStateSelector(
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
);
const query = useAppStateSelector((state) => state.query);
useEffect(() => {
if (documents) {
const newFields = getDataViewFieldList(selectedDataView, fieldCounts);
setFields(newFields);
}
}, [selectedDataView, fieldCounts, documents]);
const scrollDimensions = useResizeObserver(scrollContainer);
const onChangeFieldSearch = useCallback(
(field: string, value: string | boolean | undefined) => {
const newState = setFieldFilterProp(fieldFilter, field, value);
(filterName: string, value: string | boolean | undefined) => {
const newState = setFieldFilterProp(fieldFilter, filterName, value);
setFieldFilter(newState);
setFieldsToRender(fieldsPerPage);
},
[fieldFilter, setFieldFilter, setFieldsToRender, fieldsPerPage]
[fieldFilter, setFieldFilter]
);
const getDetailsByField = useCallback(
(ipField: DataViewField) => getDetails(ipField, documents, selectedDataView),
[documents, selectedDataView]
);
const popularLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
const {
selected: selectedFields,
popular: popularFields,
unpopular: unpopularFields,
} = useMemo(
() => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi),
[fields, columns, popularLimit, fieldCounts, fieldFilter, useNewFieldsApi]
);
/**
* Popular fields are not displayed in text based lang mode
*/
const restFields = useMemo(
() => (isPlainRecord ? [...popularFields, ...unpopularFields] : unpopularFields),
[isPlainRecord, popularFields, unpopularFields]
);
const paginate = useCallback(() => {
const newFieldsToRender = fieldsToRender + Math.round(fieldsPerPage * 0.5);
setFieldsToRender(Math.max(fieldsPerPage, Math.min(newFieldsToRender, restFields.length)));
}, [setFieldsToRender, fieldsToRender, restFields, fieldsPerPage]);
useEffect(() => {
if (scrollContainer && restFields.length && availableFieldsContainer.current) {
const { clientHeight, scrollHeight } = scrollContainer;
const isScrollable = scrollHeight > clientHeight; // there is no scrolling currently
const allFieldsRendered = fieldsToRender >= restFields.length;
if (!isScrollable && !allFieldsRendered) {
// Not all available fields were rendered with the given fieldsPerPage number
// and no scrolling is available due to the a high zoom out factor of the browser
// In this case the fieldsPerPage needs to be adapted
const fieldsRenderedHeight = availableFieldsContainer.current.clientHeight;
const avgHeightPerItem = Math.round(fieldsRenderedHeight / fieldsToRender);
const newFieldsPerPage =
(avgHeightPerItem > 0 ? Math.round(clientHeight / avgHeightPerItem) : 0) + 10;
if (newFieldsPerPage >= FIELDS_PER_PAGE && newFieldsPerPage !== fieldsPerPage) {
setFieldsPerPage(newFieldsPerPage);
setFieldsToRender(newFieldsPerPage);
}
}
}
}, [
fieldsPerPage,
scrollContainer,
restFields,
fieldsToRender,
setFieldsPerPage,
setFieldsToRender,
scrollDimensions,
]);
const lazyScroll = useCallback(() => {
if (scrollContainer) {
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
const nearBottom = scrollTop + clientHeight > scrollHeight * 0.9;
if (nearBottom && restFields) {
paginate();
}
}
}, [paginate, scrollContainer, restFields]);
const { fieldTypes, presentFieldTypes } = useMemo(() => {
const result = ['any'];
const dataViewFieldTypes = new Set<string>();
if (Array.isArray(fields)) {
for (const field of fields) {
if (Array.isArray(allFields)) {
for (const field of allFields) {
if (field.type !== '_source') {
// If it's a string type, we want to distinguish between keyword and text
// For this purpose we need the ES type
@ -242,37 +171,36 @@ export function DiscoverSidebarComponent({
}
}
return { fieldTypes: result, presentFieldTypes: Array.from(dataViewFieldTypes) };
}, [fields]);
}, [allFields]);
const showFieldStats = useMemo(() => viewMode === VIEW_MODE.DOCUMENT_LEVEL, [viewMode]);
const [selectedFieldsState, setSelectedFieldsState] = useState<SelectedFieldsResult>(
INITIAL_SELECTED_FIELDS_RESULT
);
const [multiFieldsMap, setMultiFieldsMap] = useState<
Map<string, Array<{ field: DataViewField; isSelected: boolean }>> | undefined
>(undefined);
const calculateMultiFields = () => {
if (!useNewFieldsApi || !fields) {
return undefined;
useEffect(() => {
const result = getSelectedFields(selectedDataView, columns);
setSelectedFieldsState(result);
}, [selectedDataView, columns, setSelectedFieldsState]);
useEffect(() => {
if (isPlainRecord || !useNewFieldsApi) {
setMultiFieldsMap(undefined); // we don't have to calculate multifields in this case
} else {
setMultiFieldsMap(
calculateMultiFields(allFields, selectedFieldsState.selectedFieldsMap, useNewFieldsApi)
);
}
const map = new Map<string, Array<{ field: DataViewField; isSelected: boolean }>>();
fields.forEach((field) => {
const subTypeMulti = getFieldSubtypeMulti(field);
const parent = subTypeMulti?.multi.parent;
if (!parent) {
return;
}
const multiField = {
field,
isSelected: selectedFields.includes(field),
};
const value = map.get(parent) ?? [];
value.push(multiField);
map.set(parent, value);
});
return map;
};
const [multiFields, setMultiFields] = useState(() => calculateMultiFields());
useShallowCompareEffect(() => {
setMultiFields(calculateMultiFields());
}, [fields, selectedFields, useNewFieldsApi]);
}, [
selectedFieldsState.selectedFieldsMap,
allFields,
useNewFieldsApi,
setMultiFieldsMap,
isPlainRecord,
]);
const deleteField = useMemo(
() =>
@ -305,15 +233,6 @@ export function DiscoverSidebarComponent({
]
);
const getPaginated = useCallback(
(list) => {
return list.slice(0, fieldsToRender);
},
[fieldsToRender]
);
const filterChanged = useMemo(() => isEqual(fieldFilter, getDefaultFieldFilter()), [fieldFilter]);
const visualizeAggregateQuery = useCallback(() => {
const aggregateQuery = query && isOfAggregateQueryType(query) ? query : undefined;
triggerVisualizeActionsTextBasedLanguages(
@ -325,6 +244,89 @@ export function DiscoverSidebarComponent({
);
}, [columns, selectedDataView, query]);
const popularFieldsLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
const onFilterField: GroupedFieldsParams<DataViewField>['onFilterField'] = useCallback(
(field) => {
return doesFieldMatchFilters(field, fieldFilter);
},
[fieldFilter]
);
const onSupportedFieldFilter: GroupedFieldsParams<DataViewField>['onSupportedFieldFilter'] =
useCallback(
(field) => {
return shouldShowField(field, isPlainRecord);
},
[isPlainRecord]
);
const onOverrideFieldGroupDetails: GroupedFieldsParams<DataViewField>['onOverrideFieldGroupDetails'] =
useCallback((groupName) => {
if (groupName === FieldsGroupNames.AvailableFields) {
return {
helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', {
defaultMessage: 'Fields available for display in the table.',
}),
};
}
}, []);
const fieldsExistenceReader = useExistingFieldsReader();
const fieldListGroupedProps = useGroupedFields({
dataViewId: (!isPlainRecord && selectedDataView?.id) || null, // passing `null` for text-based queries
fieldsExistenceReader: !isPlainRecord ? fieldsExistenceReader : undefined,
allFields,
popularFieldsLimit: !isPlainRecord ? popularFieldsLimit : 0,
sortedSelectedFields: selectedFieldsState.selectedFields,
isAffectedByGlobalFilter,
services: {
dataViews,
},
onFilterField,
onSupportedFieldFilter,
onOverrideFieldGroupDetails,
});
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
({ field, groupName }) => (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
field={field}
highlight={fieldFilter.name}
dataView={selectedDataView!}
onAddField={onAddField}
onRemoveField={onRemoveField}
onAddFilter={onAddFilter}
documents$={documents$}
trackUiMetric={trackUiMetric}
multiFields={multiFieldsMap?.get(field.name)} // ideally we better calculate multifields when they are requested first from the popover
onEditField={editField}
onDeleteField={deleteField}
showFieldStats={showFieldStats}
contextualFields={columns}
selected={
groupName === FieldsGroupNames.SelectedFields ||
Boolean(selectedFieldsState.selectedFieldsMap[field.name])
}
/>
</li>
),
[
alwaysShowActionButtons,
selectedDataView,
onAddField,
onRemoveField,
onAddFilter,
documents$,
trackUiMetric,
multiFieldsMap,
editField,
deleteField,
showFieldStats,
columns,
selectedFieldsState.selectedFieldsMap,
fieldFilter.name,
]
);
if (!selectedDataView) {
return null;
}
@ -367,169 +369,18 @@ export function DiscoverSidebarComponent({
types={fieldTypes}
presentFieldTypes={presentFieldTypes}
isPlainRecord={isPlainRecord}
fieldSearchDescriptionId={fieldSearchDescriptionId}
/>
</form>
</EuiFlexItem>
<EuiFlexItem className="eui-yScroll">
<div
ref={(el) => {
if (documents && el && !el.dataset.dynamicScroll) {
el.dataset.dynamicScroll = 'true';
setScrollContainer(el);
}
}}
onScroll={throttle(lazyScroll, 100)}
className="eui-yScroll"
>
{Array.isArray(fields) && fields.length > 0 && (
<div>
{selectedFields &&
selectedFields.length > 0 &&
selectedFields[0].displayName !== '_source' ? (
<>
<EuiAccordion
id="dscSelectedFields"
initialIsOpen={true}
buttonContent={
<EuiText size="xs" id="selected_fields">
<strong>
<FormattedMessage
id="discover.fieldChooser.filter.selectedFieldsTitle"
defaultMessage="Selected fields"
/>
</strong>
</EuiText>
}
extraAction={
<EuiNotificationBadge color={filterChanged ? 'subdued' : 'accent'} size="m">
{selectedFields.length}
</EuiNotificationBadge>
}
>
<EuiSpacer size="m" />
<ul
className="dscFieldList"
aria-labelledby="selected_fields"
data-test-subj={`fieldList-selected`}
>
{selectedFields.map((field: DataViewField) => {
return (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
field={field}
dataView={selectedDataView}
onAddField={onAddField}
onRemoveField={onRemoveField}
onAddFilter={onAddFilter}
getDetails={getDetailsByField}
selected={true}
trackUiMetric={trackUiMetric}
multiFields={multiFields?.get(field.name)}
onEditField={editField}
onDeleteField={deleteField}
showFieldStats={showFieldStats}
contextualFields={columns}
/>
</li>
);
})}
</ul>
</EuiAccordion>
<EuiSpacer size="s" />{' '}
</>
) : null}
<EuiAccordion
id="dscAvailableFields"
initialIsOpen={true}
buttonContent={
<EuiText size="xs" id="available_fields">
<strong id={DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields}>
<FormattedMessage
id="discover.fieldChooser.filter.availableFieldsTitle"
defaultMessage="Available fields"
/>
</strong>
</EuiText>
}
extraAction={
<EuiNotificationBadge size="m" color={filterChanged ? 'subdued' : 'accent'}>
{restFields.length}
</EuiNotificationBadge>
}
>
<EuiSpacer size="s" />
{!isPlainRecord && popularFields.length > 0 && (
<>
<EuiTitle size="xxxs" className="dscFieldListHeader">
<h4 id="available_fields_popular">
<FormattedMessage
id="discover.fieldChooser.filter.popularTitle"
defaultMessage="Popular"
/>
</h4>
</EuiTitle>
<ul
className="dscFieldList dscFieldList--popular"
aria-labelledby="available_fields available_fields_popular"
data-test-subj={`fieldList-popular`}
>
{popularFields.map((field: DataViewField) => {
return (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
field={field}
dataView={selectedDataView}
onAddField={onAddField}
onRemoveField={onRemoveField}
onAddFilter={onAddFilter}
getDetails={getDetailsByField}
trackUiMetric={trackUiMetric}
multiFields={multiFields?.get(field.name)}
onEditField={editField}
onDeleteField={deleteField}
showFieldStats={showFieldStats}
contextualFields={columns}
/>
</li>
);
})}
</ul>
</>
)}
<ul
className="dscFieldList dscFieldList--unpopular"
aria-labelledby="available_fields"
data-test-subj={`fieldList-unpopular`}
ref={availableFieldsContainer}
>
{getPaginated(restFields).map((field: DataViewField) => {
return (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
field={field}
dataView={selectedDataView}
onAddField={onAddField}
onRemoveField={onRemoveField}
onAddFilter={onAddFilter}
getDetails={getDetailsByField}
trackUiMetric={trackUiMetric}
multiFields={multiFields?.get(field.name)}
onEditField={editField}
onDeleteField={deleteField}
showFieldStats={showFieldStats}
contextualFields={columns}
/>
</li>
);
})}
</ul>
</EuiAccordion>
</div>
)}
</div>
<EuiFlexItem>
{showFieldList && (
<FieldListGrouped
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
/>
)}
</EuiFlexItem>
{!!editField && (
<EuiFlexItem grow={false}>
@ -565,3 +416,29 @@ export function DiscoverSidebarComponent({
}
export const DiscoverSidebar = memo(DiscoverSidebarComponent);
function calculateMultiFields(
allFields: DataViewField[] | null,
selectedFieldsMap: SelectedFieldsResult['selectedFieldsMap'] | undefined,
useNewFieldsApi: boolean
) {
if (!useNewFieldsApi || !allFields) {
return undefined;
}
const map = new Map<string, Array<{ field: DataViewField; isSelected: boolean }>>();
allFields.forEach((field) => {
const subTypeMulti = getFieldSubtypeMulti(field);
const parent = subTypeMulti?.multi.parent;
if (!parent) {
return;
}
const multiField = {
field,
isSelected: Boolean(selectedFieldsMap?.[field.name]),
};
const value = map.get(parent) ?? [];
value.push(multiField);
map.set(parent, value);
});
return map;
}

View file

@ -9,7 +9,8 @@
import { BehaviorSubject } from 'rxjs';
import { ReactWrapper } from 'enzyme';
import { findTestSubject } from '@elastic/eui/lib/test';
import { getDataTableRecords } from '../../../../__fixtures__/real_hits';
import { EuiProgress } from '@elastic/eui';
import { getDataTableRecords, realHits } from '../../../../__fixtures__/real_hits';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React from 'react';
@ -24,12 +25,14 @@ import { AvailableFields$, DataDocuments$, RecordRawType } from '../../hooks/use
import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list-plugin/public/services/field_existing/load_field_existing';
import { resetExistingFieldsCache } from '@kbn/unified-field-list-plugin/public/hooks/use_existing_fields';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { type DataTableRecord } from '../../../../types';
jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({
@ -67,59 +70,30 @@ jest.mock('@kbn/unified-field-list-plugin/public/services/field_stats', () => ({
}),
}));
const dataServiceMock = dataPluginMock.createStartContract();
const mockServices = {
history: () => ({
location: {
search: '',
},
}),
capabilities: {
visualize: {
show: true,
},
discover: {
save: false,
},
},
uiSettings: {
get: (key: string) => {
if (key === 'fields:popularLimit') {
return 5;
}
},
},
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
dataViewEditor: {
userPermissions: {
editDataView: jest.fn(() => true),
},
},
data: {
...dataServiceMock,
query: {
...dataServiceMock.query,
timefilter: {
...dataServiceMock.query.timefilter,
timefilter: {
...dataServiceMock.query.timefilter.timefilter,
getAbsoluteTime: () => ({
from: '2021-08-31T22:00:00.000Z',
to: '2022-09-01T09:16:29.553Z',
}),
},
function createMockServices() {
const mockServices = {
...createDiscoverServicesMock(),
capabilities: {
visualize: {
show: true,
},
discover: {
save: false,
},
getState: () => ({
query: { query: '', language: 'lucene' },
filters: [],
}),
},
},
dataViews: dataViewPluginMocks.createStartContract(),
fieldFormats: fieldFormatsServiceMock.createStartContract(),
charts: chartPluginMock.createSetupContract(),
} as unknown as DiscoverServices;
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
dataViewEditor: {
userPermissions: {
editDataView: jest.fn(() => true),
},
},
} as unknown as DiscoverServices;
return mockServices;
}
const mockfieldCounts: Record<string, number> = {};
const mockCalcFieldCounts = jest.fn(() => {
@ -138,17 +112,13 @@ jest.mock('../../utils/calc_field_counts', () => ({
calcFieldCounts: () => mockCalcFieldCounts(),
}));
function getCompProps(): DiscoverSidebarResponsiveProps {
jest.spyOn(ExistingFieldsServiceApi, 'loadFieldExisting');
function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarResponsiveProps {
const dataView = stubLogstashDataView;
dataView.toSpec = jest.fn(() => ({}));
const hits = getDataTableRecords(dataView);
const dataViewList = [
{ id: '0', title: 'b' } as DataViewListItem,
{ id: '1', title: 'a' } as DataViewListItem,
{ id: '2', title: 'c' } as DataViewListItem,
];
const hits = options?.hits ?? getDataTableRecords(dataView);
for (const hit of hits) {
for (const key of Object.keys(hit.flattened)) {
@ -166,7 +136,7 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
fetchStatus: FetchStatus.COMPLETE,
fields: [] as string[],
}) as AvailableFields$,
dataViewList,
dataViewList: [dataView as DataViewListItem],
onChangeDataView: jest.fn(),
onAddFilter: jest.fn(),
onAddField: jest.fn(),
@ -180,52 +150,220 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
};
}
function getAppStateContainer({ query }: { query?: Query | AggregateQuery }) {
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
appStateContainer.set({
query: query ?? { query: '', language: 'lucene' },
filters: [],
});
return appStateContainer;
}
async function mountComponent(
props: DiscoverSidebarResponsiveProps,
appStateParams: { query?: Query | AggregateQuery } = {},
services?: DiscoverServices
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
const mockedServices = services ?? createMockServices();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () => props.dataViewList);
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
return [props.selectedDataView].find((d) => d!.id === id);
});
await act(async () => {
comp = await mountWithIntl(
<KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
<DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
});
await comp!.update();
return comp!;
}
describe('discover responsive sidebar', function () {
let props: DiscoverSidebarResponsiveProps;
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
beforeAll(async () => {
beforeEach(async () => {
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(async () => ({
indexPatternTitle: 'test',
existingFieldNames: Object.keys(mockfieldCounts),
}));
props = getCompProps();
await act(async () => {
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
appStateContainer.set({
query: { query: '', language: 'lucene' },
filters: [],
});
afterEach(() => {
mockCalcFieldCounts.mockClear();
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockClear();
resetExistingFieldsCache();
});
it('should have loading indicators during fields existence loading', async function () {
let resolveFunction: (arg: unknown) => void;
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockReset();
(ExistingFieldsServiceApi.loadFieldExisting as jest.Mock).mockImplementation(() => {
return new Promise((resolve) => {
resolveFunction = resolve;
});
comp = await mountWithIntl(
<KibanaContextProvider services={mockServices}>
<DiscoverAppStateProvider value={appStateContainer}>
<DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
});
const compLoadingExistence = await mountComponent(props);
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
).toBe(true);
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-count').exists()
).toBe(false);
expect(compLoadingExistence.find(EuiProgress).exists()).toBe(true);
await act(async () => {
resolveFunction!({
indexPatternTitle: 'test-loaded',
existingFieldNames: Object.keys(mockfieldCounts),
});
await compLoadingExistence.update();
});
await act(async () => {
await compLoadingExistence.update();
});
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
).toBe(false);
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-count').exists()
).toBe(true);
expect(compLoadingExistence.find(EuiProgress).exists()).toBe(false);
expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1);
});
it('should have Selected Fields and Available Fields with Popular Fields sections', function () {
const popular = findTestSubject(comp, 'fieldList-popular');
const selected = findTestSubject(comp, 'fieldList-selected');
const unpopular = findTestSubject(comp, 'fieldList-unpopular');
expect(popular.children().length).toBe(1);
expect(unpopular.children().length).toBe(6);
expect(selected.children().length).toBe(1);
it('should have Selected Fields, Available Fields, Popular and Meta Fields sections', async function () {
const comp = await mountComponent(props);
const popularFieldsCount = findTestSubject(comp, 'fieldListGroupedPopularFields-count');
const selectedFieldsCount = findTestSubject(comp, 'fieldListGroupedSelectedFields-count');
const availableFieldsCount = findTestSubject(comp, 'fieldListGroupedAvailableFields-count');
const emptyFieldsCount = findTestSubject(comp, 'fieldListGroupedEmptyFields-count');
const metaFieldsCount = findTestSubject(comp, 'fieldListGroupedMetaFields-count');
const unmappedFieldsCount = findTestSubject(comp, 'fieldListGroupedUnmappedFields-count');
expect(selectedFieldsCount.text()).toBe('1');
expect(popularFieldsCount.text()).toBe('4');
expect(availableFieldsCount.text()).toBe('3');
expect(emptyFieldsCount.text()).toBe('20');
expect(metaFieldsCount.text()).toBe('2');
expect(unmappedFieldsCount.exists()).toBe(false);
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
expect(props.availableFields$.getValue()).toEqual({
fetchStatus: 'complete',
fields: ['extension'],
});
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
'1 selected field. 4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
);
expect(ExistingFieldsServiceApi.loadFieldExisting).toHaveBeenCalledTimes(1);
});
it('should allow selecting fields', function () {
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
it('should not have selected fields if no columns selected', async function () {
const propsWithoutColumns = {
...props,
columns: [],
};
const compWithoutSelected = await mountComponent(propsWithoutColumns);
const popularFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedPopularFields-count'
);
const selectedFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedSelectedFields-count'
);
const availableFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedAvailableFields-count'
);
const emptyFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedEmptyFields-count'
);
const metaFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedMetaFields-count'
);
const unmappedFieldsCount = findTestSubject(
compWithoutSelected,
'fieldListGroupedUnmappedFields-count'
);
expect(selectedFieldsCount.exists()).toBe(false);
expect(popularFieldsCount.text()).toBe('4');
expect(availableFieldsCount.text()).toBe('3');
expect(emptyFieldsCount.text()).toBe('20');
expect(metaFieldsCount.text()).toBe('2');
expect(unmappedFieldsCount.exists()).toBe(false);
expect(propsWithoutColumns.availableFields$.getValue()).toEqual({
fetchStatus: 'complete',
fields: ['bytes', 'extension', '_id', 'phpmemory'],
});
expect(findTestSubject(compWithoutSelected, 'fieldListGrouped__ariaDescription').text()).toBe(
'4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
);
});
it('should not calculate counts if documents are not fetched yet', async function () {
const propsWithoutDocuments: DiscoverSidebarResponsiveProps = {
...props,
documents$: new BehaviorSubject({
fetchStatus: FetchStatus.UNINITIALIZED,
result: undefined,
}) as DataDocuments$,
};
const compWithoutDocuments = await mountComponent(propsWithoutDocuments);
const availableFieldsCount = findTestSubject(
compWithoutDocuments,
'fieldListGroupedAvailableFields-count'
);
expect(availableFieldsCount.exists()).toBe(false);
expect(mockCalcFieldCounts.mock.calls.length).toBe(0);
expect(ExistingFieldsServiceApi.loadFieldExisting).not.toHaveBeenCalled();
});
it('should allow selecting fields', async function () {
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
findTestSubject(availableFields, 'fieldToggle-bytes').simulate('click');
expect(props.onAddField).toHaveBeenCalledWith('bytes');
});
it('should allow deselecting fields', function () {
findTestSubject(comp, 'fieldToggle-extension').simulate('click');
it('should allow deselecting fields', async function () {
const comp = await mountComponent(props);
const selectedFields = findTestSubject(comp, 'fieldListGroupedSelectedFields');
findTestSubject(selectedFields, 'fieldToggle-extension').simulate('click');
expect(props.onRemoveField).toHaveBeenCalledWith('extension');
});
it('should allow adding filters', async function () {
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
await act(async () => {
const button = findTestSubject(comp, 'field-extension-showDetails');
const button = findTestSubject(availableFields, 'field-extension-showDetails');
await button.simulate('click');
await comp.update();
});
@ -235,8 +373,10 @@ describe('discover responsive sidebar', function () {
expect(props.onAddFilter).toHaveBeenCalled();
});
it('should allow adding "exist" filter', async function () {
const comp = await mountComponent(props);
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
await act(async () => {
const button = findTestSubject(comp, 'field-extension-showDetails');
const button = findTestSubject(availableFields, 'field-extension-showDetails');
await button.simulate('click');
await comp.update();
});
@ -245,27 +385,38 @@ describe('discover responsive sidebar', function () {
findTestSubject(comp, 'discoverFieldListPanelAddExistFilter-extension').simulate('click');
expect(props.onAddFilter).toHaveBeenCalledWith('_exists_', 'extension', '+');
});
it('should allow filtering by string, and calcFieldCount should just be executed once', function () {
expect(findTestSubject(comp, 'fieldList-unpopular').children().length).toBe(6);
act(() => {
findTestSubject(comp, 'fieldFilterSearchInput').simulate('change', {
target: { value: 'abc' },
it('should allow filtering by string, and calcFieldCount should just be executed once', async function () {
const comp = await mountComponent(props);
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('3');
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
'1 selected field. 4 popular fields. 3 available fields. 20 empty fields. 2 meta fields.'
);
await act(async () => {
await findTestSubject(comp, 'fieldFilterSearchInput').simulate('change', {
target: { value: 'bytes' },
});
});
comp.update();
expect(findTestSubject(comp, 'fieldList-unpopular').children().length).toBe(4);
expect(findTestSubject(comp, 'fieldListGroupedAvailableFields-count').text()).toBe('1');
expect(findTestSubject(comp, 'fieldListGrouped__ariaDescription').text()).toBe(
'1 popular field. 1 available field. 0 empty fields. 0 meta fields.'
);
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
});
it('should show "Add a field" button to create a runtime field', () => {
expect(mockServices.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
it('should show "Add a field" button to create a runtime field', async () => {
const services = createMockServices();
const comp = await mountComponent(props, {}, services);
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
expect(findTestSubject(comp, 'dataView-add-field_btn').length).toBe(1);
});
it('should not show "Add a field" button on the sql mode', () => {
const initialProps = getCompProps();
it('should render correctly in the sql mode', async () => {
const propsWithTextBasedMode = {
...initialProps,
...props,
columns: ['extension', 'bytes'],
onAddFilter: undefined,
documents$: new BehaviorSubject({
fetchStatus: FetchStatus.COMPLETE,
@ -273,46 +424,75 @@ describe('discover responsive sidebar', function () {
result: getDataTableRecords(stubLogstashDataView),
}) as DataDocuments$,
};
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
appStateContainer.set({
const compInViewerMode = await mountComponent(propsWithTextBasedMode, {
query: { sql: 'SELECT * FROM `index`' },
});
const compInViewerMode = mountWithIntl(
<KibanaContextProvider services={mockServices}>
<DiscoverAppStateProvider value={appStateContainer}>
<DiscoverSidebarResponsive {...propsWithTextBasedMode} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0);
const popularFieldsCount = findTestSubject(
compInViewerMode,
'fieldListGroupedPopularFields-count'
);
const selectedFieldsCount = findTestSubject(
compInViewerMode,
'fieldListGroupedSelectedFields-count'
);
const availableFieldsCount = findTestSubject(
compInViewerMode,
'fieldListGroupedAvailableFields-count'
);
const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count');
const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count');
const unmappedFieldsCount = findTestSubject(
compInViewerMode,
'fieldListGroupedUnmappedFields-count'
);
expect(selectedFieldsCount.text()).toBe('2');
expect(popularFieldsCount.exists()).toBe(false);
expect(availableFieldsCount.text()).toBe('4');
expect(emptyFieldsCount.exists()).toBe(false);
expect(metaFieldsCount.exists()).toBe(false);
expect(unmappedFieldsCount.exists()).toBe(false);
expect(mockCalcFieldCounts.mock.calls.length).toBe(1);
expect(findTestSubject(compInViewerMode, 'fieldListGrouped__ariaDescription').text()).toBe(
'2 selected fields. 4 available fields.'
);
});
it('should not show "Add a field" button in viewer mode', () => {
const mockedServicesInViewerMode = {
...mockServices,
dataViewEditor: {
...mockServices.dataViewEditor,
userPermissions: {
...mockServices.dataViewEditor.userPermissions,
editDataView: jest.fn(() => false),
},
},
};
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appStateContainer;
appStateContainer.set({
query: { query: '', language: 'lucene' },
filters: [],
it('should render correctly unmapped fields', async () => {
const propsWithUnmappedField = getCompProps({
hits: [
buildDataTableRecord(realHits[0], stubLogstashDataView),
buildDataTableRecord(
{
_index: 'logstash-2014.09.09',
_id: '1945',
_score: 1,
_source: {
extension: 'gif',
bytes: 10617.2,
test_unmapped: 'show me too',
},
},
stubLogstashDataView
),
],
});
const compInViewerMode = mountWithIntl(
<KibanaContextProvider services={mockedServicesInViewerMode}>
<DiscoverAppStateProvider value={appStateContainer}>
<DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
const compWithUnmapped = await mountComponent(propsWithUnmappedField);
expect(findTestSubject(compWithUnmapped, 'fieldListGrouped__ariaDescription').text()).toBe(
'1 selected field. 4 popular fields. 3 available fields. 1 unmapped field. 20 empty fields. 2 meta fields.'
);
expect(
mockedServicesInViewerMode.dataViewEditor.userPermissions.editDataView
).toHaveBeenCalled();
});
it('should not show "Add a field" button in viewer mode', async () => {
const services = createMockServices();
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
const compInViewerMode = await mountComponent(props, {}, services);
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
expect(findTestSubject(compInViewerMode, 'dataView-add-field_btn').length).toBe(0);
});
});

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { UiCounterMetricType } from '@kbn/analytics';
@ -19,20 +19,31 @@ import {
EuiIcon,
EuiLink,
EuiPortal,
EuiProgress,
EuiShowFor,
EuiTitle,
} from '@elastic/eui';
import type { DataView, DataViewField, DataViewListItem } from '@kbn/data-views-plugin/public';
import {
useExistingFieldsFetcher,
useQuerySubscriber,
} from '@kbn/unified-field-list-plugin/public';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { getDefaultFieldFilter } from './lib/field_filter';
import { DiscoverSidebar } from './discover_sidebar';
import { AvailableFields$, DataDocuments$, RecordRawType } from '../../hooks/use_saved_search';
import { calcFieldCounts } from '../../utils/calc_field_counts';
import { VIEW_MODE } from '../../../../components/view_mode_toggle';
import { FetchStatus } from '../../../types';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import {
discoverSidebarReducer,
getInitialState,
DiscoverSidebarReducerActionType,
DiscoverSidebarReducerStatus,
} from './lib/sidebar_reducer';
import { calcFieldCounts } from '../../utils/calc_field_counts';
export interface DiscoverSidebarResponsiveProps {
/**
@ -111,38 +122,94 @@ export interface DiscoverSidebarResponsiveProps {
*/
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const services = useDiscoverServices();
const { data, dataViews, core } = services;
const isPlainRecord = useAppStateSelector(
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
);
const { selectedDataView, onFieldEdited, onDataViewCreated } = props;
const [fieldFilter, setFieldFilter] = useState(getDefaultFieldFilter());
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
/**
* fieldCounts are used to determine which fields are actually used in the given set of documents
*/
const fieldCounts = useRef<Record<string, number> | null>(null);
if (fieldCounts.current === null) {
fieldCounts.current = calcFieldCounts(props.documents$.getValue().result!, selectedDataView);
}
const [sidebarState, dispatchSidebarStateAction] = useReducer(
discoverSidebarReducer,
selectedDataView,
getInitialState
);
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
const [documentState, setDocumentState] = useState(props.documents$.getValue());
useEffect(() => {
const subscription = props.documents$.subscribe((next) => {
if (next.fetchStatus !== documentState.fetchStatus) {
if (next.result) {
fieldCounts.current = calcFieldCounts(next.result, selectedDataView!);
}
setDocumentState({ ...documentState, ...next });
const subscription = props.documents$.subscribe((documentState) => {
const isPlainRecordType = documentState.recordRawType === RecordRawType.PLAIN;
switch (documentState?.fetchStatus) {
case FetchStatus.UNINITIALIZED:
dispatchSidebarStateAction({
type: DiscoverSidebarReducerActionType.RESET,
payload: {
dataView: selectedDataViewRef.current,
},
});
break;
case FetchStatus.LOADING:
dispatchSidebarStateAction({
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING,
payload: {
isPlainRecord: isPlainRecordType,
},
});
break;
case FetchStatus.COMPLETE:
dispatchSidebarStateAction({
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED,
payload: {
dataView: selectedDataViewRef.current,
fieldCounts: calcFieldCounts(documentState.result),
isPlainRecord: isPlainRecordType,
},
});
break;
default:
break;
}
});
return () => subscription.unsubscribe();
}, [props.documents$, selectedDataView, documentState, setDocumentState]);
}, [props.documents$, dispatchSidebarStateAction, selectedDataViewRef]);
useEffect(() => {
// when data view changes fieldCounts needs to be cleaned up to prevent displaying
// fields of the previous data view
fieldCounts.current = {};
}, [selectedDataView]);
if (selectedDataView !== selectedDataViewRef.current) {
dispatchSidebarStateAction({
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED,
payload: {
dataView: selectedDataView,
},
});
selectedDataViewRef.current = selectedDataView;
}
}, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]);
const querySubscriberResult = useQuerySubscriber({ data });
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
disableAutoFetching: true,
dataViews: !isPlainRecord && sidebarState.dataView ? [sidebarState.dataView] : [],
query: querySubscriberResult.query,
filters: querySubscriberResult.filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services: {
data,
dataViews,
core,
},
});
useEffect(() => {
if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) {
refetchFieldsExistenceInfo();
}
// refetching only if status changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sidebarState.status]);
const closeFieldEditor = useRef<() => void | undefined>();
const closeDataViewEditor = useRef<() => void | undefined>();
@ -180,30 +247,18 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
const canEditDataView =
Boolean(dataViewEditor?.userPermissions.editDataView()) || !selectedDataView?.isPersisted();
useEffect(
() => {
// For an external embeddable like the Field stats
// it is useful to know what fields are populated in the docs fetched
// or what fields are selected by the user
useEffect(() => {
// For an external embeddable like the Field stats
// it is useful to know what fields are populated in the docs fetched
// or what fields are selected by the user
const fieldCnts = fieldCounts.current ?? {};
const availableFields = props.columns.length > 0 ? props.columns : Object.keys(fieldCnts);
availableFields$.next({
fetchStatus: FetchStatus.COMPLETE,
fields: availableFields,
});
},
// Using columns.length here instead of columns to avoid array reference changing
// eslint-disable-next-line react-hooks/exhaustive-deps
[
selectedDataView,
availableFields$,
fieldCounts.current,
documentState.result,
props.columns.length,
]
);
const availableFields =
props.columns.length > 0 ? props.columns : Object.keys(sidebarState.fieldCounts || {});
availableFields$.next({
fetchStatus: FetchStatus.COMPLETE,
fields: availableFields,
});
}, [selectedDataView, sidebarState.fieldCounts, props.columns, availableFields$]);
const editField = useMemo(
() =>
@ -259,14 +314,17 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
<>
{!props.isClosed && (
<EuiHideFor sizes={['xs', 's']}>
{isProcessing && <EuiProgress size="xs" color="accent" position="absolute" />}
<DiscoverSidebar
{...props}
documents={documentState.result!}
onFieldEdited={onFieldEdited}
allFields={sidebarState.allFields}
fieldFilter={fieldFilter}
fieldCounts={fieldCounts.current}
setFieldFilter={setFieldFilter}
editField={editField}
createNewDataView={createNewDataView}
showFieldList={showFieldList}
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
/>
</EuiHideFor>
)}
@ -322,8 +380,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
</EuiFlyoutHeader>
<DiscoverSidebar
{...props}
documents={documentState.result}
fieldCounts={fieldCounts.current}
onFieldEdited={onFieldEdited}
allFields={sidebarState.allFields}
fieldFilter={fieldFilter}
setFieldFilter={setFieldFilter}
alwaysShowActionButtons={true}
@ -332,6 +390,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
editField={editField}
createNewDataView={createNewDataView}
showDataViewPicker={true}
showFieldList={showFieldList}
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
/>
</EuiFlyout>
</EuiPortal>

View file

@ -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);

View file

@ -9,7 +9,6 @@
import { DataViewField } from '@kbn/data-views-plugin/public';
export interface FieldFilterState {
missing: boolean;
type: string;
name: string;
aggregatable: null | boolean;
@ -18,7 +17,6 @@ export interface FieldFilterState {
export function getDefaultFieldFilter(): FieldFilterState {
return {
missing: true,
type: 'any',
name: '',
aggregatable: null,
@ -32,9 +30,7 @@ export function setFieldFilterProp(
value: string | boolean | null | undefined
): FieldFilterState {
const newState = { ...state };
if (name === 'missing') {
newState.missing = Boolean(value);
} else if (name === 'aggregatable') {
if (name === 'aggregatable') {
newState.aggregatable = typeof value !== 'boolean' ? null : value;
} else if (name === 'searchable') {
newState.searchable = typeof value !== 'boolean' ? null : value;
@ -46,25 +42,18 @@ export function setFieldFilterProp(
return newState;
}
export function isFieldFiltered(
export function doesFieldMatchFilters(
field: DataViewField,
filterState: FieldFilterState,
fieldCounts: Record<string, number>
filterState: FieldFilterState
): boolean {
const matchFilter = filterState.type === 'any' || field.type === filterState.type;
const isAggregatable =
filterState.aggregatable === null || field.aggregatable === filterState.aggregatable;
const isSearchable =
filterState.searchable === null || field.searchable === filterState.searchable;
const scriptedOrMissing =
!filterState.missing ||
field.type === '_source' ||
field.type === 'unknown_selected' ||
field.scripted ||
fieldCounts[field.name] > 0;
const needle = filterState.name ? filterState.name.toLowerCase() : '';
const haystack = `${field.name}${field.displayName || ''}`.toLowerCase();
const matchName = !filterState.name || haystack.indexOf(needle) !== -1;
return matchFilter && isAggregatable && isSearchable && scriptedOrMissing && matchName;
return matchFilter && isAggregatable && isSearchable && matchName;
}

View file

@ -7,18 +7,37 @@
*/
import { difference } from 'lodash';
import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { fieldWildcardFilter } from '@kbn/kibana-utils-plugin/public';
import { isNestedFieldParent } from '../../../utils/nested_fields';
export function getDataViewFieldList(dataView?: DataView, fieldCounts?: Record<string, number>) {
if (!dataView || !fieldCounts) return [];
export function getDataViewFieldList(
dataView: DataView | undefined | null,
fieldCounts: Record<string, number> | undefined | null,
isPlainRecord: boolean
): DataViewField[] | null {
if (isPlainRecord && !fieldCounts) {
// still loading data
return null;
}
const fieldNamesInDocs = Object.keys(fieldCounts);
const fieldNamesInDataView = dataView.fields.getAll().map((fld) => fld.name);
const currentFieldCounts = fieldCounts || {};
const sourceFiltersValues = dataView?.getSourceFiltering?.()?.excludes;
let dataViewFields: DataViewField[] = dataView?.fields.getAll() || [];
if (sourceFiltersValues) {
const filter = fieldWildcardFilter(sourceFiltersValues, dataView.metaFields);
dataViewFields = dataViewFields.filter((field) => {
return filter(field.name) || currentFieldCounts[field.name]; // don't filter out a field which was present in hits (ex. for text-based queries, selected fields)
});
}
const fieldNamesInDocs = Object.keys(currentFieldCounts);
const fieldNamesInDataView = dataViewFields.map((fld) => fld.name);
const unknownFields: DataViewField[] = [];
difference(fieldNamesInDocs, fieldNamesInDataView).forEach((unknownFieldName) => {
if (isNestedFieldParent(unknownFieldName, dataView)) {
if (dataView && isNestedFieldParent(unknownFieldName, dataView)) {
unknownFields.push({
displayName: String(unknownFieldName),
name: String(unknownFieldName),
@ -33,5 +52,10 @@ export function getDataViewFieldList(dataView?: DataView, fieldCounts?: Record<s
}
});
return [...dataView.fields.getAll(), ...unknownFields];
return [
...(isPlainRecord
? dataViewFields.filter((field) => currentFieldCounts[field.name])
: dataViewFields),
...unknownFields,
];
}

View file

@ -6,271 +6,106 @@
* Side Public License, v 1.
*/
import { groupFields } from './group_fields';
import { getDefaultFieldFilter } from './field_filter';
import { DataViewField } from '@kbn/data-views-plugin/public';
const fields = [
{
name: 'category',
type: 'string',
esTypes: ['text'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'currency',
type: 'string',
esTypes: ['keyword'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
{
name: 'customer_birth_date',
type: 'date',
esTypes: ['date'],
count: 0,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
},
];
const fieldCounts = {
category: 1,
currency: 1,
customer_birth_date: 1,
unknown_field: 1,
};
import { type DataViewField } from '@kbn/data-plugin/common';
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { getSelectedFields, shouldShowField, INITIAL_SELECTED_FIELDS_RESULT } from './group_fields';
describe('group_fields', function () {
it('should group fields in selected, popular, unpopular group', function () {
const fieldFilterState = getDefaultFieldFilter();
const actual = groupFields(
fields as DataViewField[],
['currency'],
5,
fieldCounts,
fieldFilterState,
false
);
it('should pick fields as unknown_selected if they are unknown', function () {
const actual = getSelectedFields(dataView, ['currency']);
expect(actual).toMatchInlineSnapshot(`
Object {
"popular": Array [
"selectedFields": Array [
Object {
"aggregatable": true,
"count": 1,
"esTypes": Array [
"text",
],
"name": "category",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"type": "string",
},
],
"selected": Array [
Object {
"aggregatable": true,
"count": 0,
"esTypes": Array [
"keyword",
],
"displayName": "currency",
"name": "currency",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"type": "string",
},
],
"unpopular": Array [
Object {
"aggregatable": true,
"count": 0,
"esTypes": Array [
"date",
],
"name": "customer_birth_date",
"readFromDocValues": true,
"scripted": false,
"searchable": true,
"type": "date",
"type": "unknown_selected",
},
],
"selectedFieldsMap": Object {
"currency": true,
},
}
`);
});
it('should group fields in selected, popular, unpopular group if they contain multifields', function () {
const category = {
name: 'category',
type: 'string',
esTypes: ['text'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
};
const currency = {
name: 'currency',
displayName: 'currency',
kbnFieldType: {
esTypes: ['string', 'text', 'keyword', '_type', '_id'],
filterable: true,
name: 'string',
sortable: true,
},
spec: {
esTypes: ['text'],
name: 'category',
},
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
};
const currencyKeyword = {
name: 'currency.keyword',
displayName: 'currency.keyword',
type: 'string',
esTypes: ['keyword'],
kbnFieldType: {
esTypes: ['string', 'text', 'keyword', '_type', '_id'],
filterable: true,
name: 'string',
sortable: true,
},
spec: {
aggregatable: true,
esTypes: ['keyword'],
name: 'category.keyword',
readFromDocValues: true,
searchable: true,
shortDotsEnable: false,
subType: {
multi: {
parent: 'currency',
},
},
},
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: false,
};
const fieldsToGroup = [category, currency, currencyKeyword] as DataViewField[];
const fieldFilterState = getDefaultFieldFilter();
const actual = groupFields(fieldsToGroup, ['currency'], 5, fieldCounts, fieldFilterState, true);
expect(actual.popular).toEqual([category]);
expect(actual.selected).toEqual([currency]);
expect(actual.unpopular).toEqual([]);
it('should work correctly if no columns selected', function () {
expect(getSelectedFields(dataView, [])).toBe(INITIAL_SELECTED_FIELDS_RESULT);
expect(getSelectedFields(dataView, ['_source'])).toBe(INITIAL_SELECTED_FIELDS_RESULT);
});
it('should sort selected fields by columns order ', function () {
const fieldFilterState = getDefaultFieldFilter();
it('should pick fields into selected group', function () {
const actual = getSelectedFields(dataView, ['bytes', '@timestamp']);
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
expect(actual.selectedFieldsMap).toStrictEqual({
bytes: true,
'@timestamp': true,
});
});
const actual1 = groupFields(
fields as DataViewField[],
['customer_birth_date', 'currency', 'unknown'],
5,
fieldCounts,
fieldFilterState,
false
);
expect(actual1.selected.map((field) => field.name)).toEqual([
'customer_birth_date',
'currency',
it('should pick fields into selected group if they contain multifields', function () {
const actual = getSelectedFields(dataView, ['machine.os', 'machine.os.raw']);
expect(actual.selectedFields.map((field) => field.name)).toEqual([
'machine.os',
'machine.os.raw',
]);
expect(actual.selectedFieldsMap).toStrictEqual({
'machine.os': true,
'machine.os.raw': true,
});
});
it('should sort selected fields by columns order', function () {
const actual1 = getSelectedFields(dataView, ['bytes', 'extension.keyword', 'unknown']);
expect(actual1.selectedFields.map((field) => field.name)).toEqual([
'bytes',
'extension.keyword',
'unknown',
]);
const actual2 = groupFields(
fields as DataViewField[],
['currency', 'customer_birth_date', 'unknown'],
5,
fieldCounts,
fieldFilterState,
false
);
expect(actual2.selected.map((field) => field.name)).toEqual([
'currency',
'customer_birth_date',
'unknown',
]);
});
it('should filter fields by a given name', function () {
const fieldFilterState = { ...getDefaultFieldFilter(), ...{ name: 'curr' } };
const actual1 = groupFields(
fields as DataViewField[],
['customer_birth_date', 'currency', 'unknown'],
5,
fieldCounts,
fieldFilterState,
false
);
expect(actual1.selected.map((field) => field.name)).toEqual(['currency']);
});
it('excludes unmapped fields if showUnmappedFields set to false', function () {
const fieldFilterState = getDefaultFieldFilter();
const fieldsWithUnmappedField = [...fields];
fieldsWithUnmappedField.push({
name: 'unknown_field',
type: 'unknown',
esTypes: ['unknown'],
count: 1,
scripted: false,
searchable: true,
aggregatable: true,
readFromDocValues: true,
expect(actual1.selectedFieldsMap).toStrictEqual({
bytes: true,
'extension.keyword': true,
unknown: true,
});
const actual = groupFields(
fieldsWithUnmappedField as DataViewField[],
['customer_birth_date', 'currency'],
5,
fieldCounts,
fieldFilterState,
const actual2 = getSelectedFields(dataView, ['extension', 'bytes', 'unknown']);
expect(actual2.selectedFields.map((field) => field.name)).toEqual([
'extension',
'bytes',
'unknown',
]);
expect(actual2.selectedFieldsMap).toStrictEqual({
extension: true,
bytes: true,
unknown: true,
});
});
it('should show any fields if for text-based searches', function () {
expect(shouldShowField(dataView.getFieldByName('bytes'), true)).toBe(true);
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, true)).toBe(true);
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, true)).toBe(false);
});
it('should show fields excluding subfields when searched from source', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
true
);
expect(actual.unpopular).toEqual([]);
});
it('includes unmapped fields when reading from source', function () {
const fieldFilterState = getDefaultFieldFilter();
const fieldsWithUnmappedField = [...fields];
fieldsWithUnmappedField.push({
name: 'unknown_field',
type: 'unknown',
esTypes: ['unknown'],
count: 0,
scripted: false,
searchable: false,
aggregatable: false,
readFromDocValues: false,
});
const actual = groupFields(
fieldsWithUnmappedField as DataViewField[],
['customer_birth_date', 'currency'],
5,
fieldCounts,
fieldFilterState,
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
false
);
});
it('should show fields excluding subfields when fields api is used', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
true
);
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
false
);
expect(actual.unpopular.map((field) => field.name)).toEqual(['unknown_field']);
});
});

View file

@ -6,90 +6,66 @@
* Side Public License, v 1.
*/
import { DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import { FieldFilterState, isFieldFiltered } from './field_filter';
import { uniqBy } from 'lodash';
import {
type DataViewField,
type DataView,
getFieldSubtypeMulti,
} from '@kbn/data-views-plugin/public';
interface GroupedFields {
selected: DataViewField[];
popular: DataViewField[];
unpopular: DataViewField[];
export function shouldShowField(field: DataViewField | undefined, isPlainRecord: boolean): boolean {
if (!field?.type || field.type === '_source') {
return false;
}
if (isPlainRecord) {
// exclude only `_source` for plain records
return true;
}
// exclude subfields
return !getFieldSubtypeMulti(field?.spec);
}
/**
* group the fields into selected, popular and unpopular, filter by fieldFilterState
*/
export function groupFields(
fields: DataViewField[] | null,
columns: string[],
popularLimit: number,
fieldCounts: Record<string, number> | undefined,
fieldFilterState: FieldFilterState,
useNewFieldsApi: boolean
): GroupedFields {
const showUnmappedFields = useNewFieldsApi;
const result: GroupedFields = {
selected: [],
popular: [],
unpopular: [],
// to avoid rerenderings for empty state
export const INITIAL_SELECTED_FIELDS_RESULT = {
selectedFields: [],
selectedFieldsMap: {},
};
export interface SelectedFieldsResult {
selectedFields: DataViewField[];
selectedFieldsMap: Record<string, boolean>;
}
export function getSelectedFields(
dataView: DataView | undefined,
columns: string[]
): SelectedFieldsResult {
const result: SelectedFieldsResult = {
selectedFields: [],
selectedFieldsMap: {},
};
if (!Array.isArray(fields) || !Array.isArray(columns) || typeof fieldCounts !== 'object') {
return result;
if (!Array.isArray(columns) || !columns.length) {
return INITIAL_SELECTED_FIELDS_RESULT;
}
const popular = fields
.filter((field) => !columns.includes(field.name) && field.count)
.sort((a: DataViewField, b: DataViewField) => (b.count || 0) - (a.count || 0))
.map((field) => field.name)
.slice(0, popularLimit);
const compareFn = (a: DataViewField, b: DataViewField) => {
if (!a.displayName) {
return 0;
}
return a.displayName.localeCompare(b.displayName || '');
};
const fieldsSorted = fields.sort(compareFn);
for (const field of fieldsSorted) {
if (!isFieldFiltered(field, fieldFilterState, fieldCounts)) {
continue;
}
const subTypeMulti = getFieldSubtypeMulti(field?.spec);
const isSubfield = useNewFieldsApi && subTypeMulti;
if (columns.includes(field.name)) {
result.selected.push(field);
} else if (popular.includes(field.name) && field.type !== '_source') {
if (!isSubfield) {
result.popular.push(field);
}
} else if (field.type !== '_source') {
// do not show unmapped fields unless explicitly specified
// do not add subfields to this list
if (useNewFieldsApi && (field.type !== 'unknown' || showUnmappedFields) && !isSubfield) {
result.unpopular.push(field);
} else if (!useNewFieldsApi) {
result.unpopular.push(field);
}
}
}
// add selected columns, that are not part of the data view, to be removable
for (const column of columns) {
const tmpField = {
name: column,
displayName: column,
type: 'unknown_selected',
} as DataViewField;
if (
!result.selected.find((field) => field.name === column) &&
isFieldFiltered(tmpField, fieldFilterState, fieldCounts)
) {
result.selected.push(tmpField);
}
const selectedField =
dataView?.getFieldByName?.(column) ||
({
name: column,
displayName: column,
type: 'unknown_selected',
} as DataViewField);
result.selectedFields.push(selectedField);
result.selectedFieldsMap[selectedField.name] = true;
}
result.selectedFields = uniqBy(result.selectedFields, 'name');
if (result.selectedFields.length === 1 && result.selectedFields[0].name === '_source') {
return INITIAL_SELECTED_FIELDS_RESULT;
}
result.selected.sort((a, b) => {
return columns.indexOf(a.name) - columns.indexOf(b.name);
});
return result;
}

View file

@ -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,
})
);
});
});

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common';
import { getDataViewFieldList } from './get_data_view_field_list';
export enum DiscoverSidebarReducerActionType {
RESET = 'RESET',
DATA_VIEW_SWITCHED = 'DATA_VIEW_SWITCHED',
DOCUMENTS_LOADED = 'DOCUMENTS_LOADED',
DOCUMENTS_LOADING = 'DOCUMENTS_LOADING',
}
type DiscoverSidebarReducerAction =
| {
type: DiscoverSidebarReducerActionType.RESET;
payload: {
dataView: DataView | null | undefined;
};
}
| {
type: DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED;
payload: {
dataView: DataView | null | undefined;
};
}
| {
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADING;
payload: {
isPlainRecord: boolean;
};
}
| {
type: DiscoverSidebarReducerActionType.DOCUMENTS_LOADED;
payload: {
fieldCounts: DiscoverSidebarReducerState['fieldCounts'];
isPlainRecord: boolean;
dataView: DataView | null | undefined;
};
};
export enum DiscoverSidebarReducerStatus {
INITIAL = 'INITIAL',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
}
export interface DiscoverSidebarReducerState {
dataView: DataView | null | undefined;
allFields: DataViewField[] | null;
fieldCounts: Record<string, number> | null;
status: DiscoverSidebarReducerStatus;
}
export function getInitialState(dataView?: DataView | null): DiscoverSidebarReducerState {
return {
dataView,
allFields: null,
fieldCounts: null,
status: DiscoverSidebarReducerStatus.INITIAL,
};
}
export function discoverSidebarReducer(
state: DiscoverSidebarReducerState,
action: DiscoverSidebarReducerAction
): DiscoverSidebarReducerState {
switch (action.type) {
case DiscoverSidebarReducerActionType.RESET:
return getInitialState(action.payload.dataView);
case DiscoverSidebarReducerActionType.DATA_VIEW_SWITCHED:
return state.dataView === action.payload.dataView
? state // already updated in `DOCUMENTS_LOADED`
: {
...state,
dataView: action.payload.dataView,
fieldCounts: null,
allFields: null,
status:
state.status === DiscoverSidebarReducerStatus.COMPLETED
? DiscoverSidebarReducerStatus.INITIAL
: state.status,
};
case DiscoverSidebarReducerActionType.DOCUMENTS_LOADING:
return {
...state,
fieldCounts: null,
allFields: action.payload.isPlainRecord ? null : state.allFields,
status: DiscoverSidebarReducerStatus.PROCESSING,
};
case DiscoverSidebarReducerActionType.DOCUMENTS_LOADED:
const mappedAndUnmappedFields = getDataViewFieldList(
action.payload.dataView,
action.payload.fieldCounts,
action.payload.isPlainRecord
);
return {
...state,
dataView: action.payload.dataView,
fieldCounts: action.payload.fieldCounts,
allFields: mappedAndUnmappedFields,
status:
mappedAndUnmappedFields === null
? DiscoverSidebarReducerStatus.PROCESSING
: DiscoverSidebarReducerStatus.COMPLETED,
};
}
return state;
}

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DataViewListItem } from '@kbn/data-views-plugin/public';
import { dataViewMock } from '../../__mocks__/data_view';
@ -23,7 +24,7 @@ setHeaderActionMenuMounter(jest.fn());
setUrlTracker(urlTrackerMock);
describe('DiscoverMainApp', () => {
test('renders', () => {
test('renders', async () => {
const dataViewList = [dataViewMock].map((ip) => {
return { ...ip, ...{ attributes: { title: ip.title } } };
}) as unknown as DataViewListItem[];
@ -35,15 +36,21 @@ describe('DiscoverMainApp', () => {
initialEntries: ['/'],
});
const component = mountWithIntl(
<Router history={history}>
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverMainApp {...props} />
</KibanaContextProvider>
</Router>
);
await act(async () => {
const component = await mountWithIntl(
<Router history={history}>
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverMainApp {...props} />
</KibanaContextProvider>
</Router>
);
expect(component.find(DiscoverTopNav).exists()).toBe(true);
expect(component.find(DiscoverTopNav).prop('dataView')).toEqual(dataViewMock);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await component.update();
expect(component.find(DiscoverTopNav).exists()).toBe(true);
expect(component.find(DiscoverTopNav).prop('dataView')).toEqual(dataViewMock);
});
});
});

View file

@ -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,

View file

@ -5,24 +5,24 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { DataView } from '@kbn/data-views-plugin/public';
import { DataTableRecord } from '../../../types';
/**
* This function is calculating stats of the available fields, for usage in sidebar and sharing
* Note that this values aren't displayed, but used for internal calculations
*/
export function calcFieldCounts(rows?: DataTableRecord[], dataView?: DataView) {
export function calcFieldCounts(rows?: DataTableRecord[]) {
const counts: Record<string, number> = {};
if (!rows || !dataView) {
if (!rows) {
return {};
}
for (const hit of rows) {
rows.forEach((hit) => {
const fields = Object.keys(hit.flattened);
for (const fieldName of fields) {
fields.forEach((fieldName) => {
counts[fieldName] = (counts[fieldName] || 0) + 1;
}
}
});
});
return counts;
}

View file

@ -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",
},

View file

@ -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"]',

View file

@ -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');
});
});

View file

@ -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;
};

View file

@ -81,18 +81,16 @@ const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({
...
});
const fieldsExistenceReader = useExistingFieldsReader()
const { fieldGroups } = useGroupedFields({
dataViewId: currentDataViewId,
allFields,
fieldsExistenceReader,
const fieldListGroupedProps = useGroupedFields({
dataViewId: currentDataViewId, // pass `null` here for text-based queries to skip fields existence check
allFields, // pass `null` to show loading indicators
fieldsExistenceReader, // pass `undefined` for text-based queries
...
});
// and now we can render a field list
<FieldListGrouped
fieldGroups={fieldGroups}
fieldsExistenceStatus={fieldsExistenceReader.getFieldsExistenceStatus(currentDataViewId)}
fieldsExistInIndex={!!allFields.length}
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
/>

View file

@ -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,
};
});

View file

@ -24,7 +24,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
let defaultProps: FieldListGroupedProps<DataViewField>;
let mockedServices: GroupedFieldsParams<DataViewField>['services'];
const allFields = dataView.fields;
// 5 times more fields. Added fields will be treated as empty as they are not a part of the data view.
// 5 times more fields. Added fields will be treated as Unmapped as they are not a part of the data view.
const manyFields = [...new Array(5)].flatMap((_, index) =>
allFields.map((field) => {
return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` });
@ -44,6 +44,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
defaultProps = {
fieldGroups: {},
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
scrollToTopResetCounter: 0,
fieldsExistInIndex: true,
screenReaderDescriptionForSearchInputId: 'testId',
renderFieldItem: jest.fn(({ field, itemIndex, groupIndex }) => (
@ -268,10 +269,23 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
expect(
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
).toStrictEqual([25, 0, 0]);
).toStrictEqual([25, 0, 0, 0]);
await act(async () => {
await wrapper
.find('[data-test-subj="fieldListGroupedUnmappedFields"]')
.find('button')
.first()
.simulate('click');
await wrapper.update();
});
expect(
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
).toStrictEqual([25, 50, 0, 0]);
await act(async () => {
await wrapper
@ -284,20 +298,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
).toStrictEqual([25, 50, 0]);
await act(async () => {
await wrapper
.find('[data-test-subj="fieldListGroupedMetaFields"]')
.find('button')
.first()
.simulate('click');
await wrapper.update();
});
expect(
wrapper.find(FieldsAccordion).map((accordion) => accordion.prop('paginatedFields').length)
).toStrictEqual([25, 88, 0]);
).toStrictEqual([25, 88, 0, 0]);
});
it('renders correctly when filtered', async () => {
@ -315,7 +316,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
await act(async () => {
await wrapper.setProps({
@ -329,7 +330,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('2 available fields. 8 empty fields. 0 meta fields.');
).toBe('2 available fields. 8 unmapped fields. 0 empty fields. 0 meta fields.');
await act(async () => {
await wrapper.setProps({
@ -343,7 +344,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('0 available fields. 12 empty fields. 3 meta fields.');
).toBe('0 available fields. 12 unmapped fields. 0 empty fields. 3 meta fields.');
});
it('renders correctly when non-supported fields are filtered out', async () => {
@ -361,7 +362,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
await act(async () => {
await wrapper.setProps({
@ -375,7 +376,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('23 available fields. 104 empty fields. 3 meta fields.');
).toBe('23 available fields. 104 unmapped fields. 0 empty fields. 3 meta fields.');
});
it('renders correctly when selected fields are present', async () => {
@ -393,7 +394,7 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('25 available fields. 112 empty fields. 3 meta fields.');
).toBe('25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.');
await act(async () => {
await wrapper.setProps({
@ -408,6 +409,30 @@ describe('UnifiedFieldList <FieldListGrouped /> + useGroupedFields()', () => {
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe('2 selected fields. 25 available fields. 112 empty fields. 3 meta fields.');
).toBe(
'2 selected fields. 25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.'
);
});
it('renders correctly when popular fields limit and custom selected fields are present', async () => {
const hookParams = {
dataViewId: dataView.id!,
allFields: manyFields,
popularFieldsLimit: 10,
sortedSelectedFields: [manyFields[0], manyFields[1]],
};
const wrapper = await mountGroupedList({
listProps: {
...defaultProps,
fieldsExistenceStatus: ExistenceFetchStatus.succeeded,
},
hookParams,
});
expect(
wrapper.find(`#${defaultProps.screenReaderDescriptionForSearchInputId}`).first().text()
).toBe(
'2 selected fields. 10 popular fields. 25 available fields. 112 unmapped fields. 0 empty fields. 3 meta fields.'
);
});
});

View file

@ -7,14 +7,14 @@
*/
import { partition, throttle } from 'lodash';
import React, { useState, Fragment, useCallback, useMemo } from 'react';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiScreenReaderOnly, EuiSpacer } from '@elastic/eui';
import { type DataViewField } from '@kbn/data-views-plugin/common';
import { NoFieldsCallout } from './no_fields_callout';
import { FieldsAccordion, type FieldsAccordionProps } from './fields_accordion';
import { FieldsAccordion, type FieldsAccordionProps, getFieldKey } from './fields_accordion';
import type { FieldListGroups, FieldListItem } from '../../types';
import { ExistenceFetchStatus } from '../../types';
import { ExistenceFetchStatus, FieldsGroup, FieldsGroupNames } from '../../types';
import './field_list_grouped.scss';
const PAGINATION_SIZE = 50;
@ -33,6 +33,7 @@ export interface FieldListGroupedProps<T extends FieldListItem> {
fieldsExistenceStatus: ExistenceFetchStatus;
fieldsExistInIndex: boolean;
renderFieldItem: FieldsAccordionProps<T>['renderFieldItem'];
scrollToTopResetCounter: number;
screenReaderDescriptionForSearchInputId?: string;
'data-test-subj'?: string;
}
@ -42,6 +43,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
fieldsExistenceStatus,
fieldsExistInIndex,
renderFieldItem,
scrollToTopResetCounter,
screenReaderDescriptionForSearchInputId,
'data-test-subj': dataTestSubject = 'fieldListGrouped',
}: FieldListGroupedProps<T>) {
@ -60,6 +62,14 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
)
);
useEffect(() => {
// Reset the scroll if we have made material changes to the field list
if (scrollContainer && scrollToTopResetCounter) {
scrollContainer.scrollTop = 0;
setPageSize(PAGINATION_SIZE);
}
}, [scrollToTopResetCounter, scrollContainer]);
const lazyScroll = useCallback(() => {
if (scrollContainer) {
const nearBottom =
@ -93,9 +103,12 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
);
}, [pageSize, fieldGroupsToShow, accordionState]);
const hasSpecialFields = Boolean(fieldGroupsToCollapse[0]?.[1]?.fields?.length);
return (
<div
className="unifiedFieldList__fieldListGrouped"
data-test-subj={`${dataTestSubject}FieldGroups`}
ref={(el) => {
if (el && !el.dataset.dynamicScroll) {
el.dataset.dynamicScroll = 'true';
@ -114,9 +127,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
>
{hasSyncedExistingFields
? [
fieldGroups.SelectedFields &&
(!fieldGroups.SelectedFields?.hideIfEmpty ||
fieldGroups.SelectedFields?.fields?.length > 0) &&
shouldIncludeGroupDescriptionInAria(fieldGroups.SelectedFields) &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForSelectedFieldsLiveRegion',
{
@ -127,6 +138,17 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
},
}
),
shouldIncludeGroupDescriptionInAria(fieldGroups.PopularFields) &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForPopularFieldsLiveRegion',
{
defaultMessage:
'{popularFields} popular {popularFields, plural, one {field} other {fields}}.',
values: {
popularFields: fieldGroups.PopularFields?.fields?.length || 0,
},
}
),
fieldGroups.AvailableFields?.fields &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForAvailableFieldsLiveRegion',
@ -138,9 +160,18 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
},
}
),
fieldGroups.EmptyFields &&
(!fieldGroups.EmptyFields?.hideIfEmpty ||
fieldGroups.EmptyFields?.fields?.length > 0) &&
shouldIncludeGroupDescriptionInAria(fieldGroups.UnmappedFields) &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForUnmappedFieldsLiveRegion',
{
defaultMessage:
'{unmappedFields} unmapped {unmappedFields, plural, one {field} other {fields}}.',
values: {
unmappedFields: fieldGroups.UnmappedFields?.fields?.length || 0,
},
}
),
shouldIncludeGroupDescriptionInAria(fieldGroups.EmptyFields) &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForEmptyFieldsLiveRegion',
{
@ -151,9 +182,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
},
}
),
fieldGroups.MetaFields &&
(!fieldGroups.MetaFields?.hideIfEmpty ||
fieldGroups.MetaFields?.fields?.length > 0) &&
shouldIncludeGroupDescriptionInAria(fieldGroups.MetaFields) &&
i18n.translate(
'unifiedFieldList.fieldListGrouped.fieldSearchForMetaFieldsLiveRegion',
{
@ -171,16 +200,26 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
</div>
</EuiScreenReaderOnly>
)}
<ul>
{fieldGroupsToCollapse.flatMap(([, { fields }]) =>
fields.map((field, index) => (
<Fragment key={field.name}>
{renderFieldItem({ field, itemIndex: index, groupIndex: 0, hideDetails: true })}
</Fragment>
))
)}
</ul>
<EuiSpacer size="s" />
{hasSpecialFields && (
<>
<ul>
{fieldGroupsToCollapse.flatMap(([key, { fields }]) =>
fields.map((field, index) => (
<Fragment key={getFieldKey(field)}>
{renderFieldItem({
field,
itemIndex: index,
groupIndex: 0,
groupName: key as FieldsGroupNames,
hideDetails: true,
})}
</Fragment>
))
)}
</ul>
<EuiSpacer size="s" />
</>
)}
{fieldGroupsToShow.map(([key, fieldGroup], index) => {
const hidden = Boolean(fieldGroup.hideIfEmpty) && !fieldGroup.fields.length;
if (hidden) {
@ -199,6 +238,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
isFiltered={fieldGroup.fieldCount !== fieldGroup.fields.length}
paginatedFields={paginatedFields[key]}
groupIndex={index + 1}
groupName={key as FieldsGroupNames}
onToggle={(open) => {
setAccordionState((s) => ({
...s,
@ -224,6 +264,7 @@ function InnerFieldListGrouped<T extends FieldListItem = DataViewField>({
isAffectedByFieldFilter={fieldGroup.fieldCount !== fieldGroup.fields.length}
fieldsExistInIndex={!!fieldsExistInIndex}
defaultNoFieldsMessage={fieldGroup.defaultNoFieldsMessage}
data-test-subj={`${dataTestSubject}${key}NoFieldsCallout`}
/>
)}
renderFieldItem={renderFieldItem}
@ -243,3 +284,13 @@ const FieldListGrouped = React.memo(InnerFieldListGrouped) as GenericFieldListGr
// Necessary for React.lazy
// eslint-disable-next-line import/no-default-export
export default FieldListGrouped;
function shouldIncludeGroupDescriptionInAria<T extends FieldListItem>(
group: FieldsGroup<T> | undefined
): boolean {
if (!group) {
return false;
}
// has some fields or an empty list should be still shown
return group.fields?.length > 0 || !group.hideIfEmpty;
}

View file

@ -11,7 +11,7 @@ import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/
import { EuiLoadingSpinner, EuiNotificationBadge, EuiText } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { FieldsAccordion, FieldsAccordionProps } from './fields_accordion';
import { FieldListItem } from '../../types';
import { FieldListItem, FieldsGroupNames } from '../../types';
describe('UnifiedFieldList <FieldsAccordion />', () => {
let defaultProps: FieldsAccordionProps<FieldListItem>;
@ -21,7 +21,8 @@ describe('UnifiedFieldList <FieldsAccordion />', () => {
defaultProps = {
initialIsOpen: true,
onToggle: jest.fn(),
groupIndex: 0,
groupIndex: 1,
groupName: FieldsGroupNames.AvailableFields,
id: 'id',
label: 'label-test',
hasLoaded: true,

View file

@ -18,7 +18,7 @@ import {
} from '@elastic/eui';
import classNames from 'classnames';
import { type DataViewField } from '@kbn/data-views-plugin/common';
import type { FieldListItem } from '../../types';
import { type FieldListItem, FieldsGroupNames } from '../../types';
import './fields_accordion.scss';
export interface FieldsAccordionProps<T extends FieldListItem> {
@ -32,12 +32,14 @@ export interface FieldsAccordionProps<T extends FieldListItem> {
hideDetails?: boolean;
isFiltered: boolean;
groupIndex: number;
groupName: FieldsGroupNames;
paginatedFields: T[];
renderFieldItem: (params: {
field: T;
hideDetails?: boolean;
itemIndex: number;
groupIndex: number;
groupName: FieldsGroupNames;
}) => JSX.Element;
renderCallout: () => JSX.Element;
showExistenceFetchError?: boolean;
@ -55,6 +57,7 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
hideDetails,
isFiltered,
groupIndex,
groupName,
paginatedFields,
renderFieldItem,
renderCallout,
@ -99,6 +102,9 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
content={i18n.translate('unifiedFieldList.fieldsAccordion.existenceErrorLabel', {
defaultMessage: "Field information can't be loaded",
})}
iconProps={{
'data-test-subj': `${id}-fetchWarning`,
}}
/>
);
}
@ -128,7 +134,7 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
);
}
return <EuiLoadingSpinner size="m" />;
return <EuiLoadingSpinner size="m" data-test-subj={`${id}-countLoading`} />;
}, [showExistenceFetchError, showExistenceFetchTimeout, hasLoaded, isFiltered, id, fieldsCount]);
return (
@ -146,8 +152,8 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
<ul className="unifiedFieldList__fieldsAccordion__fieldItems">
{paginatedFields &&
paginatedFields.map((field, index) => (
<Fragment key={field.name}>
{renderFieldItem({ field, itemIndex: index, groupIndex, hideDetails })}
<Fragment key={getFieldKey(field)}>
{renderFieldItem({ field, itemIndex: index, groupIndex, groupName, hideDetails })}
</Fragment>
))}
</ul>
@ -159,3 +165,6 @@ function InnerFieldsAccordion<T extends FieldListItem = DataViewField>({
}
export const FieldsAccordion = React.memo(InnerFieldsAccordion) as typeof InnerFieldsAccordion;
export const getFieldKey = (field: FieldListItem): string =>
`${field.name}-${field.displayName}-${field.type}`;

View file

@ -16,6 +16,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsExist"
size="s"
title="No fields exist in this data view."
/>
@ -26,6 +27,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="There are no fields."
/>
@ -38,6 +40,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="No empty fields"
/>
@ -51,6 +54,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="No fields match the selected filters."
>
@ -78,6 +82,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="There are no available fields that contain data."
>
@ -108,6 +113,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="No fields match the selected filters."
>
@ -139,6 +145,7 @@ describe('UnifiedFieldList <NoFieldCallout />', () => {
expect(component).toMatchInlineSnapshot(`
<EuiCallOut
color="warning"
data-test-subj="noFieldsCallout-noFieldsMatch"
size="s"
title="No fields match the selected filters."
>

View file

@ -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) && (
<>

View file

@ -8,6 +8,7 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { ReactWrapper } from 'enzyme';
import { EuiLoadingSpinner, EuiProgress } from '@elastic/eui';
import { coreMock } from '@kbn/core/public/mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
@ -120,6 +121,18 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
async function mountComponent(component: React.ReactElement): Promise<ReactWrapper> {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = await mountWithIntl(component);
// wait for lazy modules if any
await new Promise((resolve) => setTimeout(resolve, 0));
await wrapper.update();
});
return wrapper!;
}
beforeEach(() => {
(loadFieldStats as jest.Mock).mockReset();
(loadFieldStats as jest.Mock).mockImplementation(() => Promise.resolve({}));
@ -134,7 +147,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
const wrapper = mountWithIntl(
const wrapper = await mountComponent(
<FieldStats
{...defaultProps}
query={{ query: 'geo.src : "US"', language: 'kuery' }}
@ -149,8 +162,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
/>
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalledWith({
abortController: new AbortController(),
services: { data: mockedServices.data },
@ -260,33 +271,27 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
it('should not request field stats for range fields', async () => {
const wrapper = await mountWithIntl(
const wrapper = await mountComponent(
<FieldStats {...defaultProps} field={dataView.fields.find((f) => f.name === 'ip_range')!} />
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalled();
expect(wrapper.text()).toBe('Analysis is not available for this field.');
});
it('should not request field stats for geo fields', async () => {
const wrapper = await mountWithIntl(
const wrapper = await mountComponent(
<FieldStats {...defaultProps} field={dataView.fields.find((f) => f.name === 'geo_shape')!} />
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalled();
expect(wrapper.text()).toBe('Analysis is not available for this field.');
});
it('should render a message if no data is found', async () => {
const wrapper = await mountWithIntl(<FieldStats {...defaultProps} />);
await wrapper.update();
const wrapper = await mountComponent(<FieldStats {...defaultProps} />);
expect(loadFieldStats).toHaveBeenCalled();
@ -302,9 +307,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
const wrapper = mountWithIntl(<FieldStats {...defaultProps} />);
await wrapper.update();
const wrapper = await mountComponent(<FieldStats {...defaultProps} />);
await act(async () => {
resolveFunction!({
@ -330,7 +333,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
const wrapper = mountWithIntl(
const wrapper = await mountComponent(
<FieldStats
{...defaultProps}
query={{ language: 'kuery', query: '' }}
@ -340,8 +343,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
/>
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalledWith({
abortController: new AbortController(),
services: { data: mockedServices.data },
@ -433,7 +434,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
const wrapper = mountWithIntl(
const wrapper = await mountComponent(
<FieldStats
{...defaultProps}
field={
@ -446,8 +447,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
/>
);
await wrapper.update();
expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1);
await act(async () => {
@ -507,7 +506,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
});
});
const wrapper = mountWithIntl(
const wrapper = await mountComponent(
<FieldStats
{...defaultProps}
field={dataView.fields[0]}
@ -518,8 +517,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
/>
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalledWith({
abortController: new AbortController(),
services: { data: mockedServices.data },
@ -615,7 +612,7 @@ describe('UnifiedFieldList <FieldStats />', () => {
const field = dataView.fields.find((f) => f.name === 'machine.ram')!;
const wrapper = mountWithIntl(
const wrapper = await mountComponent(
<FieldStats
{...defaultProps}
field={field}
@ -626,8 +623,6 @@ describe('UnifiedFieldList <FieldStats />', () => {
/>
);
await wrapper.update();
expect(loadFieldStats).toHaveBeenCalledWith({
abortController: new AbortController(),
services: { data: mockedServices.data },

View file

@ -378,33 +378,36 @@ const FieldStatsComponent: React.FC<FieldStatsProps> = ({
if (histogram && histogram.buckets.length && topValues && topValues.buckets.length) {
title = (
<EuiButtonGroup
buttonSize="compressed"
isFullWidth
legend={i18n.translate('unifiedFieldList.fieldStats.displayToggleLegend', {
defaultMessage: 'Toggle either the',
})}
options={[
{
label: i18n.translate('unifiedFieldList.fieldStats.topValuesLabel', {
defaultMessage: 'Top values',
}),
id: 'topValues',
'data-test-subj': `${dataTestSubject}-buttonGroup-topValuesButton`,
},
{
label: i18n.translate('unifiedFieldList.fieldStats.fieldDistributionLabel', {
defaultMessage: 'Distribution',
}),
id: 'histogram',
'data-test-subj': `${dataTestSubject}-buttonGroup-distributionButton`,
},
]}
onChange={(optionId: string) => {
setShowingHistogram(optionId === 'histogram');
}}
idSelected={showingHistogram ? 'histogram' : 'topValues'}
/>
<>
<EuiButtonGroup
buttonSize="compressed"
isFullWidth
legend={i18n.translate('unifiedFieldList.fieldStats.displayToggleLegend', {
defaultMessage: 'Toggle either the',
})}
options={[
{
label: i18n.translate('unifiedFieldList.fieldStats.topValuesLabel', {
defaultMessage: 'Top values',
}),
id: 'topValues',
'data-test-subj': `${dataTestSubject}-buttonGroup-topValuesButton`,
},
{
label: i18n.translate('unifiedFieldList.fieldStats.fieldDistributionLabel', {
defaultMessage: 'Distribution',
}),
id: 'histogram',
'data-test-subj': `${dataTestSubject}-buttonGroup-distributionButton`,
},
]}
onChange={(optionId: string) => {
setShowingHistogram(optionId === 'histogram');
}}
idSelected={showingHistogram ? 'histogram' : 'topValues'}
/>
<EuiSpacer size="xs" />
</>
);
} else if (field.type === 'date') {
title = (

View file

@ -0,0 +1,259 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UnifiedFieldList useGroupedFields() should work correctly for no data 1`] = `
Object {
"AvailableFields": Object {
"defaultNoFieldsMessage": "There are no available fields that contain data.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Available fields",
},
"EmptyFields": Object {
"defaultNoFieldsMessage": "There are no empty fields.",
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that don't have any values based on your filters.",
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Empty fields",
},
"MetaFields": Object {
"defaultNoFieldsMessage": "There are no meta fields.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Meta fields",
},
"PopularFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that your organization frequently uses, from most to least popular.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Popular fields",
},
"SelectedFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Selected fields",
},
"SpecialFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": false,
"title": "",
},
"UnmappedFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that aren't explicitly mapped to a field data type.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Unmapped fields",
},
}
`;
exports[`UnifiedFieldList useGroupedFields() should work correctly in loading state 1`] = `
Object {
"AvailableFields": Object {
"defaultNoFieldsMessage": "There are no available fields that contain data.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Available fields",
},
"EmptyFields": Object {
"defaultNoFieldsMessage": "There are no empty fields.",
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that don't have any values based on your filters.",
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Empty fields",
},
"MetaFields": Object {
"defaultNoFieldsMessage": "There are no meta fields.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Meta fields",
},
"PopularFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that your organization frequently uses, from most to least popular.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Popular fields",
},
"SelectedFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Selected fields",
},
"SpecialFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": false,
"title": "",
},
"UnmappedFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that aren't explicitly mapped to a field data type.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Unmapped fields",
},
}
`;
exports[`UnifiedFieldList useGroupedFields() should work correctly when global filters are set 1`] = `
Object {
"AvailableFields": Object {
"defaultNoFieldsMessage": "There are no available fields that contain data.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"isAffectedByGlobalFilter": true,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Available fields",
},
"EmptyFields": Object {
"defaultNoFieldsMessage": "There are no empty fields.",
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that don't have any values based on your filters.",
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Empty fields",
},
"MetaFields": Object {
"defaultNoFieldsMessage": "There are no meta fields.",
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": false,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Meta fields",
},
"PopularFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that your organization frequently uses, from most to least popular.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": true,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Popular fields",
},
"SelectedFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": true,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": true,
"showInAccordion": true,
"title": "Selected fields",
},
"SpecialFields": Object {
"fieldCount": 0,
"fields": Array [],
"hideDetails": true,
"isAffectedByGlobalFilter": false,
"isAffectedByTimeFilter": false,
"isInitiallyOpen": false,
"showInAccordion": false,
"title": "",
},
"UnmappedFields": Object {
"fieldCount": 0,
"fields": Array [],
"helpText": "Fields that aren't explicitly mapped to a field data type.",
"hideDetails": false,
"hideIfEmpty": true,
"isAffectedByGlobalFilter": true,
"isAffectedByTimeFilter": true,
"isInitiallyOpen": false,
"showInAccordion": true,
"title": "Unmapped fields",
},
}
`;

View file

@ -15,7 +15,6 @@ import {
DataPublicPluginStart,
DataViewsContract,
getEsQueryConfig,
UI_SETTINGS,
} from '@kbn/data-plugin/public';
import { type DataView } from '@kbn/data-plugin/common';
import { loadFieldExisting } from '../services/field_existing';
@ -32,11 +31,12 @@ export interface ExistingFieldsInfo {
}
export interface ExistingFieldsFetcherParams {
disableAutoFetching?: boolean;
dataViews: DataView[];
fromDate: string;
toDate: string;
query: Query | AggregateQuery;
filters: Filter[];
fromDate: string | undefined; // fetching will be skipped if `undefined`
toDate: string | undefined;
query: Query | AggregateQuery | undefined;
filters: Filter[] | undefined;
services: {
core: Pick<CoreStart, 'uiSettings'>;
data: DataPublicPluginStart;
@ -89,7 +89,7 @@ export const useExistingFieldsFetcher = (
dataViewId: string | undefined;
fetchId: string;
}): Promise<void> => {
if (!dataViewId) {
if (!dataViewId || !query || !fromDate || !toDate) {
return;
}
@ -123,7 +123,7 @@ export const useExistingFieldsFetcher = (
dslQuery: await buildSafeEsQuery(
dataView,
query,
filters,
filters || [],
getEsQueryConfig(core.uiSettings)
),
fromDate,
@ -137,11 +137,11 @@ export const useExistingFieldsFetcher = (
const existingFieldNames = result?.existingFieldNames || [];
const metaFields = core.uiSettings.get(UI_SETTINGS.META_FIELDS) || [];
if (
!existingFieldNames.filter((fieldName) => !metaFields.includes?.(fieldName)).length &&
onNoData &&
numberOfFetches === 1 &&
onNoData
!existingFieldNames.filter((fieldName) => !dataView?.metaFields?.includes(fieldName))
.length
) {
onNoData(dataViewId);
}
@ -173,12 +173,17 @@ export const useExistingFieldsFetcher = (
async (dataViewId?: string) => {
const fetchId = generateId();
lastFetchId = fetchId;
const options = {
fetchId,
dataViewId,
...params,
};
// refetch only for the specified data view
if (dataViewId) {
await fetchFieldsExistenceInfo({
fetchId,
...options,
dataViewId,
...params,
});
return;
}
@ -186,9 +191,8 @@ export const useExistingFieldsFetcher = (
await Promise.all(
params.dataViews.map((dataView) =>
fetchFieldsExistenceInfo({
fetchId,
...options,
dataViewId: dataView.id,
...params,
})
)
);
@ -205,8 +209,10 @@ export const useExistingFieldsFetcher = (
);
useEffect(() => {
refetchFieldsExistenceInfo();
}, [refetchFieldsExistenceInfo]);
if (!params.disableAutoFetching) {
refetchFieldsExistenceInfo();
}
}, [refetchFieldsExistenceInfo, params.disableAutoFetching]);
useEffect(() => {
return () => {

View file

@ -20,6 +20,12 @@ import { ExistenceFetchStatus, FieldListGroups, FieldsGroupNames } from '../type
describe('UnifiedFieldList useGroupedFields()', () => {
let mockedServices: GroupedFieldsParams<DataViewField>['services'];
const allFields = dataView.fields;
// Added fields will be treated as Unmapped as they are not a part of the data view.
const allFieldsIncludingUnmapped = [...new Array(2)].flatMap((_, index) =>
allFields.map((field) => {
return new DataViewField({ ...field.toSpec(), name: `${field.name}${index || ''}` });
})
);
const anotherDataView = createStubDataView({
spec: {
id: 'another-data-view',
@ -39,14 +45,43 @@ describe('UnifiedFieldList useGroupedFields()', () => {
});
});
it('should work correctly in loading state', async () => {
const props: GroupedFieldsParams<DataViewField> = {
dataViewId: dataView.id!,
allFields: null,
services: mockedServices,
};
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
initialProps: props,
});
await waitForNextUpdate();
expect(result.current.fieldGroups).toMatchSnapshot();
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
expect(result.current.fieldsExistInIndex).toBe(false);
expect(result.current.scrollToTopResetCounter).toBeTruthy();
rerender({
...props,
dataViewId: null, // for text-based queries
allFields: null,
});
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
expect(result.current.fieldsExistInIndex).toBe(true);
expect(result.current.scrollToTopResetCounter).toBeTruthy();
});
it('should work correctly for no data', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
dataViewId: dataView.id!,
allFields: [],
services: mockedServices,
})
);
const props: GroupedFieldsParams<DataViewField> = {
dataViewId: dataView.id!,
allFields: [],
services: mockedServices,
};
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
initialProps: props,
});
await waitForNextUpdate();
@ -59,20 +94,36 @@ describe('UnifiedFieldList useGroupedFields()', () => {
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-0',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-0',
]);
expect(fieldGroups).toMatchSnapshot();
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(false);
rerender({
...props,
dataViewId: null, // for text-based queries
allFields: [],
});
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(true);
});
it('should work correctly with fields', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
dataViewId: dataView.id!,
allFields,
services: mockedServices,
})
);
const props: GroupedFieldsParams<DataViewField> = {
dataViewId: dataView.id!,
allFields,
services: mockedServices,
};
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
initialProps: props,
});
await waitForNextUpdate();
@ -85,48 +136,116 @@ describe('UnifiedFieldList useGroupedFields()', () => {
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-25',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-3',
]);
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(true);
rerender({
...props,
dataViewId: null, // for text-based queries
allFields,
});
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(true);
});
it('should work correctly when filtered', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
dataViewId: dataView.id!,
allFields,
services: mockedServices,
onFilterField: (field: DataViewField) => field.name.startsWith('@'),
})
);
const props: GroupedFieldsParams<DataViewField> = {
dataViewId: dataView.id!,
allFields: allFieldsIncludingUnmapped,
services: mockedServices,
};
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
initialProps: props,
});
await waitForNextUpdate();
const fieldGroups = result.current.fieldGroups;
let fieldGroups = result.current.fieldGroups;
const scrollToTopResetCounter1 = result.current.scrollToTopResetCounter;
expect(
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
(key) =>
`${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}-${
fieldGroups![key as FieldsGroupNames]?.fieldCount
}`
)
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'AvailableFields-2',
'EmptyFields-0',
'MetaFields-0',
'SpecialFields-0-0',
'SelectedFields-0-0',
'PopularFields-0-0',
'AvailableFields-25-25',
'UnmappedFields-28-28',
'EmptyFields-0-0',
'MetaFields-3-3',
]);
rerender({
...props,
onFilterField: (field: DataViewField) => field.name.startsWith('@'),
});
fieldGroups = result.current.fieldGroups;
expect(
Object.keys(fieldGroups!).map(
(key) =>
`${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}-${
fieldGroups![key as FieldsGroupNames]?.fieldCount
}`
)
).toStrictEqual([
'SpecialFields-0-0',
'SelectedFields-0-0',
'PopularFields-0-0',
'AvailableFields-2-25',
'UnmappedFields-2-28',
'EmptyFields-0-0',
'MetaFields-0-3',
]);
expect(result.current.scrollToTopResetCounter).not.toBe(scrollToTopResetCounter1);
});
it('should not change the scroll position if fields list is extended', async () => {
const props: GroupedFieldsParams<DataViewField> = {
dataViewId: dataView.id!,
allFields,
services: mockedServices,
};
const { result, waitForNextUpdate, rerender } = renderHook(useGroupedFields, {
initialProps: props,
});
await waitForNextUpdate();
const scrollToTopResetCounter1 = result.current.scrollToTopResetCounter;
rerender({
...props,
allFields: allFieldsIncludingUnmapped,
});
expect(result.current.scrollToTopResetCounter).toBe(scrollToTopResetCounter1);
});
it('should work correctly when custom unsupported fields are skipped', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields,
services: mockedServices,
onSupportedFieldFilter: (field: DataViewField) => field.aggregatable,
})
);
},
});
await waitForNextUpdate();
@ -139,22 +258,24 @@ describe('UnifiedFieldList useGroupedFields()', () => {
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-23',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-3',
]);
});
it('should work correctly when selected fields are present', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields,
services: mockedServices,
onSelectedFieldFilter: (field: DataViewField) =>
['bytes', 'extension', '_id', '@timestamp'].includes(field.name),
})
);
},
});
await waitForNextUpdate();
@ -167,20 +288,22 @@ describe('UnifiedFieldList useGroupedFields()', () => {
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-4',
'PopularFields-0',
'AvailableFields-25',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-3',
]);
});
it('should work correctly for text-based queries (no data view)', async () => {
const { result } = renderHook(() =>
useGroupedFields({
const { result } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: null,
allFields,
allFields: allFieldsIncludingUnmapped,
services: mockedServices,
})
);
},
});
const fieldGroups = result.current.fieldGroups;
@ -188,24 +311,36 @@ describe('UnifiedFieldList useGroupedFields()', () => {
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
)
).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-28', 'MetaFields-0']);
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-56', // even unmapped fields fall into Available
'UnmappedFields-0',
'MetaFields-0',
]);
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(true);
});
it('should work correctly when details are overwritten', async () => {
const { result, waitForNextUpdate } = renderHook(() =>
useGroupedFields({
const onOverrideFieldGroupDetails: GroupedFieldsParams<DataViewField>['onOverrideFieldGroupDetails'] =
jest.fn((groupName) => {
if (groupName === FieldsGroupNames.SelectedFields) {
return {
helpText: 'test',
};
}
});
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields,
services: mockedServices,
onOverrideFieldGroupDetails: (groupName) => {
if (groupName === FieldsGroupNames.SelectedFields) {
return {
helpText: 'test',
};
}
},
})
);
onOverrideFieldGroupDetails,
},
});
await waitForNextUpdate();
@ -213,6 +348,7 @@ describe('UnifiedFieldList useGroupedFields()', () => {
expect(fieldGroups[FieldsGroupNames.SelectedFields]?.helpText).toBe('test');
expect(fieldGroups[FieldsGroupNames.AvailableFields]?.helpText).not.toBe('test');
expect(onOverrideFieldGroupDetails).toHaveBeenCalled();
});
it('should work correctly when changing a data view and existence info is available only for one of them', async () => {
@ -248,11 +384,16 @@ describe('UnifiedFieldList useGroupedFields()', () => {
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-2',
'UnmappedFields-0',
'EmptyFields-23',
'MetaFields-3',
]);
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.succeeded);
expect(result.current.fieldsExistInIndex).toBe(true);
rerender({
...props,
dataViewId: anotherDataView.id!,
@ -267,6 +408,133 @@ describe('UnifiedFieldList useGroupedFields()', () => {
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
)
).toStrictEqual(['SpecialFields-0', 'SelectedFields-0', 'AvailableFields-8', 'MetaFields-0']);
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-8',
'UnmappedFields-0',
'MetaFields-0',
]);
expect(result.current.fieldsExistenceStatus).toBe(ExistenceFetchStatus.unknown);
expect(result.current.fieldsExistInIndex).toBe(true);
});
it('should work correctly when popular fields limit is present', async () => {
// `bytes` is popular, but we are skipping it here to test that it would not be shown under Popular and Available
const onSupportedFieldFilter = jest.fn((field) => field.name !== 'bytes');
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields,
popularFieldsLimit: 10,
services: mockedServices,
onSupportedFieldFilter,
},
});
await waitForNextUpdate();
const fieldGroups = result.current.fieldGroups;
expect(
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
)
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-3',
'AvailableFields-24',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-3',
]);
expect(fieldGroups.PopularFields?.fields.map((field) => field.name).join(',')).toBe(
'@timestamp,time,ssl'
);
});
it('should work correctly when global filters are set', async () => {
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields: [],
isAffectedByGlobalFilter: true,
services: mockedServices,
},
});
await waitForNextUpdate();
const fieldGroups = result.current.fieldGroups;
expect(fieldGroups).toMatchSnapshot();
});
it('should work correctly and show unmapped fields separately', async () => {
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields: allFieldsIncludingUnmapped,
services: mockedServices,
},
});
await waitForNextUpdate();
const fieldGroups = result.current.fieldGroups;
expect(
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
)
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-0',
'PopularFields-0',
'AvailableFields-25',
'UnmappedFields-28',
'EmptyFields-0',
'MetaFields-3',
]);
});
it('should work correctly when custom selected fields are provided', async () => {
const customSortedFields = [
allFieldsIncludingUnmapped[allFieldsIncludingUnmapped.length - 1],
allFields[2],
allFields[0],
];
const { result, waitForNextUpdate } = renderHook(useGroupedFields, {
initialProps: {
dataViewId: dataView.id!,
allFields,
sortedSelectedFields: customSortedFields,
services: mockedServices,
},
});
await waitForNextUpdate();
const fieldGroups = result.current.fieldGroups;
expect(
Object.keys(fieldGroups!).map(
(key) => `${key}-${fieldGroups![key as FieldsGroupNames]?.fields.length}`
)
).toStrictEqual([
'SpecialFields-0',
'SelectedFields-3',
'PopularFields-0',
'AvailableFields-25',
'UnmappedFields-0',
'EmptyFields-0',
'MetaFields-3',
]);
expect(fieldGroups.SelectedFields?.fields).toBe(customSortedFields);
});
});

View file

@ -17,16 +17,20 @@ import {
type FieldsGroup,
type FieldListItem,
FieldsGroupNames,
ExistenceFetchStatus,
} from '../types';
import { type ExistingFieldsReader } from './use_existing_fields';
export interface GroupedFieldsParams<T extends FieldListItem> {
dataViewId: string | null; // `null` is for text-based queries
allFields: T[];
allFields: T[] | null; // `null` is for loading indicator
services: {
dataViews: DataViewsContract;
};
fieldsExistenceReader?: ExistingFieldsReader;
fieldsExistenceReader?: ExistingFieldsReader; // use `undefined` for text-based queries
isAffectedByGlobalFilter?: boolean;
popularFieldsLimit?: number;
sortedSelectedFields?: T[];
onOverrideFieldGroupDetails?: (
groupName: FieldsGroupNames
) => Partial<FieldsGroupDetails> | undefined | null;
@ -37,6 +41,9 @@ export interface GroupedFieldsParams<T extends FieldListItem> {
export interface GroupedFieldsResult<T extends FieldListItem> {
fieldGroups: FieldListGroups<T>;
scrollToTopResetCounter: number;
fieldsExistenceStatus: ExistenceFetchStatus;
fieldsExistInIndex: boolean;
}
export function useGroupedFields<T extends FieldListItem = DataViewField>({
@ -44,12 +51,16 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
allFields,
services,
fieldsExistenceReader,
isAffectedByGlobalFilter = false,
popularFieldsLimit,
sortedSelectedFields,
onOverrideFieldGroupDetails,
onSupportedFieldFilter,
onSelectedFieldFilter,
onFilterField,
}: GroupedFieldsParams<T>): GroupedFieldsResult<T> {
const [dataView, setDataView] = useState<DataView | null>(null);
const isAffectedByTimeFilter = Boolean(dataView?.timeFieldName);
const fieldsExistenceInfoUnavailable: boolean = dataViewId
? fieldsExistenceReader?.isFieldsExistenceInfoUnavailable(dataViewId) ?? false
: true;
@ -68,33 +79,59 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
// if field existence information changed, reload the data view too
}, [dataViewId, services.dataViews, setDataView, hasFieldDataHandler]);
// important when switching from a known dataViewId to no data view (like in text-based queries)
useEffect(() => {
if (dataView && !dataViewId) {
setDataView(null);
}
}, [dataView, setDataView, dataViewId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToTopResetCounter: number = useMemo(() => Date.now(), [dataViewId, onFilterField]);
const unfilteredFieldGroups: FieldListGroups<T> = useMemo(() => {
const containsData = (field: T) => {
if (!dataViewId || !dataView) {
return true;
}
const overallField = dataView.getFieldByName?.(field.name);
return Boolean(overallField && hasFieldDataHandler(dataViewId, overallField.name));
return dataViewId ? hasFieldDataHandler(dataViewId, field.name) : true;
};
const fields = allFields || [];
const allSupportedTypesFields = onSupportedFieldFilter
? fields.filter(onSupportedFieldFilter)
: fields;
const sortedFields = [...allSupportedTypesFields].sort(sortFields);
const selectedFields = sortedSelectedFields || [];
const sortedFields = [...(allFields || [])].sort(sortFields);
const groupedFields = {
...getDefaultFieldGroups(),
...groupBy(sortedFields, (field) => {
if (!sortedSelectedFields && onSelectedFieldFilter && onSelectedFieldFilter(field)) {
selectedFields.push(field);
}
if (onSupportedFieldFilter && !onSupportedFieldFilter(field)) {
return 'skippedFields';
}
if (field.type === 'document') {
return 'specialFields';
} else if (dataView?.metaFields?.includes(field.name)) {
}
if (dataView?.metaFields?.includes(field.name)) {
return 'metaFields';
} else if (containsData(field)) {
}
if (dataView?.getFieldByName && !dataView.getFieldByName(field.name)) {
return 'unmappedFields';
}
if (containsData(field) || fieldsExistenceInfoUnavailable) {
return 'availableFields';
} else return 'emptyFields';
}
return 'emptyFields';
}),
};
const selectedFields = onSelectedFieldFilter ? sortedFields.filter(onSelectedFieldFilter) : [];
const popularFields = popularFieldsLimit
? sortedFields
.filter(
(field) =>
field.count &&
field.type !== '_source' &&
(!onSupportedFieldFilter || onSupportedFieldFilter(field))
)
.sort((a: T, b: T) => (b.count || 0) - (a.count || 0)) // sort by popularity score
.slice(0, popularFieldsLimit)
: [];
let fieldGroupDefinitions: FieldListGroups<T> = {
SpecialFields: {
@ -115,8 +152,25 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
title: i18n.translate('unifiedFieldList.useGroupedFields.selectedFieldsLabel', {
defaultMessage: 'Selected fields',
}),
isAffectedByGlobalFilter: false,
isAffectedByTimeFilter: true,
isAffectedByGlobalFilter,
isAffectedByTimeFilter,
hideDetails: false,
hideIfEmpty: true,
},
PopularFields: {
fields: popularFields,
fieldCount: popularFields.length,
isInitiallyOpen: true,
showInAccordion: true,
title: i18n.translate('unifiedFieldList.useGroupedFields.popularFieldsLabel', {
defaultMessage: 'Popular fields',
}),
helpText: i18n.translate('unifiedFieldList.useGroupedFields.popularFieldsLabelHelp', {
defaultMessage:
'Fields that your organization frequently uses, from most to least popular.',
}),
isAffectedByGlobalFilter,
isAffectedByTimeFilter,
hideDetails: false,
hideIfEmpty: true,
},
@ -133,8 +187,8 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
: i18n.translate('unifiedFieldList.useGroupedFields.availableFieldsLabel', {
defaultMessage: 'Available fields',
}),
isAffectedByGlobalFilter: false,
isAffectedByTimeFilter: true,
isAffectedByGlobalFilter,
isAffectedByTimeFilter,
// Show details on timeout but not failure
// hideDetails: fieldsExistenceInfoUnavailable && !existenceFetchTimeout, // TODO: is this check still necessary?
hideDetails: fieldsExistenceInfoUnavailable,
@ -145,6 +199,22 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
}
),
},
UnmappedFields: {
fields: groupedFields.unmappedFields,
fieldCount: groupedFields.unmappedFields.length,
isAffectedByGlobalFilter,
isAffectedByTimeFilter,
isInitiallyOpen: false,
showInAccordion: true,
hideDetails: false,
hideIfEmpty: true,
title: i18n.translate('unifiedFieldList.useGroupedFields.unmappedFieldsLabel', {
defaultMessage: 'Unmapped fields',
}),
helpText: i18n.translate('unifiedFieldList.useGroupedFields.unmappedFieldsLabelHelp', {
defaultMessage: "Fields that aren't explicitly mapped to a field data type.",
}),
},
EmptyFields: {
fields: groupedFields.emptyFields,
fieldCount: groupedFields.emptyFields.length,
@ -157,15 +227,15 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
title: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabel', {
defaultMessage: 'Empty fields',
}),
helpText: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp', {
defaultMessage: "Fields that don't have any values based on your filters.",
}),
defaultNoFieldsMessage: i18n.translate(
'unifiedFieldList.useGroupedFields.noEmptyDataLabel',
{
defaultMessage: `There are no empty fields.`,
}
),
helpText: i18n.translate('unifiedFieldList.useGroupedFields.emptyFieldsLabelHelp', {
defaultMessage: 'Empty fields did not contain any values based on your filters.',
}),
},
MetaFields: {
fields: groupedFields.metaFields,
@ -220,6 +290,10 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
dataViewId,
hasFieldDataHandler,
fieldsExistenceInfoUnavailable,
isAffectedByGlobalFilter,
isAffectedByTimeFilter,
popularFieldsLimit,
sortedSelectedFields,
]);
const fieldGroups: FieldListGroups<T> = useMemo(() => {
@ -235,22 +309,39 @@ export function useGroupedFields<T extends FieldListItem = DataViewField>({
) as FieldListGroups<T>;
}, [unfilteredFieldGroups, onFilterField]);
return useMemo(
() => ({
const hasDataLoaded = Boolean(allFields);
const allFieldsLength = allFields?.length;
const fieldsExistInIndex = useMemo(() => {
return dataViewId ? Boolean(allFieldsLength) : true;
}, [dataViewId, allFieldsLength]);
const fieldsExistenceStatus = useMemo(() => {
if (!hasDataLoaded) {
return ExistenceFetchStatus.unknown; // to show loading indicator in the list
}
if (!dataViewId || !fieldsExistenceReader) {
// ex. for text-based queries
return ExistenceFetchStatus.succeeded;
}
return fieldsExistenceReader.getFieldsExistenceStatus(dataViewId);
}, [dataViewId, hasDataLoaded, fieldsExistenceReader]);
return useMemo(() => {
return {
fieldGroups,
}),
[fieldGroups]
);
scrollToTopResetCounter,
fieldsExistInIndex,
fieldsExistenceStatus,
};
}, [fieldGroups, scrollToTopResetCounter, fieldsExistInIndex, fieldsExistenceStatus]);
}
const collator = new Intl.Collator(undefined, {
sensitivity: 'base',
});
function sortFields<T extends FieldListItem>(fieldA: T, fieldB: T) {
return (fieldA.displayName || fieldA.name).localeCompare(
fieldB.displayName || fieldB.name,
undefined,
{
sensitivity: 'base',
}
);
return collator.compare(fieldA.displayName || fieldA.name, fieldB.displayName || fieldB.name);
}
function hasFieldDataByDefault(): boolean {
@ -263,5 +354,7 @@ function getDefaultFieldGroups() {
availableFields: [],
emptyFields: [],
metaFields: [],
unmappedFields: [],
skippedFields: [],
};
}

View file

@ -9,6 +9,7 @@
import { useEffect, useState } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
import { getResolvedDateRange } from '../utils/get_resolved_date_range';
/**
* Hook params
@ -23,32 +24,68 @@ export interface QuerySubscriberParams {
export interface QuerySubscriberResult {
query: Query | AggregateQuery | undefined;
filters: Filter[] | undefined;
fromDate: string | undefined;
toDate: string | undefined;
}
/**
* Memorizes current query and filters
* Memorizes current query, filters and absolute date range
* @param data
* @public
*/
export const useQuerySubscriber = ({ data }: QuerySubscriberParams) => {
const timefilter = data.query.timefilter.timefilter;
const [result, setResult] = useState<QuerySubscriberResult>(() => {
const state = data.query.getState();
const dateRange = getResolvedDateRange(timefilter);
return {
query: state?.query,
filters: state?.filters,
fromDate: dateRange.fromDate,
toDate: dateRange.toDate,
};
});
useEffect(() => {
const subscription = data.query.state$.subscribe(({ state }) => {
const subscription = data.search.session.state$.subscribe((sessionState) => {
const dateRange = getResolvedDateRange(timefilter);
setResult((prevState) => ({
...prevState,
query: state.query,
filters: state.filters,
fromDate: dateRange.fromDate,
toDate: dateRange.toDate,
}));
});
return () => subscription.unsubscribe();
}, [setResult, timefilter, data.search.session.state$]);
useEffect(() => {
const subscription = data.query.state$.subscribe(({ state, changes }) => {
if (changes.query || changes.filters) {
setResult((prevState) => ({
...prevState,
query: state.query,
filters: state.filters,
}));
}
});
return () => subscription.unsubscribe();
}, [setResult, data.query.state$]);
return result;
};
/**
* Checks if query result is ready to be used
* @param result
* @public
*/
export const hasQuerySubscriberData = (
result: QuerySubscriberResult
): result is {
query: Query | AggregateQuery;
filters: Filter[];
fromDate: string;
toDate: string;
} => Boolean(result.query && result.filters && result.fromDate && result.toDate);

View file

@ -76,6 +76,7 @@ export {
export {
useQuerySubscriber,
hasQuerySubscriberData,
type QuerySubscriberResult,
type QuerySubscriberParams,
} from './hooks/use_query_subscriber';

View file

@ -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 {

View file

@ -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 };
};

View file

@ -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'
);
});
});
});
}

View file

@ -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');
});

View file

@ -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');
});
});
}

View file

@ -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);
});
});
}

View file

@ -9,6 +9,8 @@
import expect from '@kbn/expect';
import { FtrService } from '../ftr_provider_context';
type SidebarSectionName = 'meta' | 'empty' | 'available' | 'unmapped' | 'popular' | 'selected';
export class DiscoverPageObject extends FtrService {
private readonly retry = this.ctx.getService('retry');
private readonly testSubjects = this.ctx.getService('testSubjects');
@ -437,8 +439,61 @@ export class DiscoverPageObject extends FtrService {
return await this.testSubjects.exists('discoverNoResultsTimefilter');
}
public async getSidebarAriaDescription(): Promise<string> {
return await (
await this.testSubjects.find('fieldListGrouped__ariaDescription')
).getAttribute('innerText');
}
public async waitUntilSidebarHasLoaded() {
await this.retry.waitFor('sidebar is loaded', async () => {
return (await this.getSidebarAriaDescription()).length > 0;
});
}
public async doesSidebarShowFields() {
return await this.testSubjects.exists('fieldListGroupedFieldGroups');
}
public getSidebarSectionSelector(
sectionName: SidebarSectionName,
asCSSSelector: boolean = false
) {
const testSubj = `fieldListGrouped${sectionName[0].toUpperCase()}${sectionName.substring(
1
)}Fields`;
if (!asCSSSelector) {
return testSubj;
}
return `[data-test-subj="${testSubj}"]`;
}
public async getSidebarSectionFieldNames(sectionName: SidebarSectionName): Promise<string[]> {
const elements = await this.find.allByCssSelector(
`${this.getSidebarSectionSelector(sectionName, true)} li`
);
if (!elements?.length) {
return [];
}
return Promise.all(
elements.map(async (element) => await element.getAttribute('data-attr-field'))
);
}
public async toggleSidebarSection(sectionName: SidebarSectionName) {
return await this.find.clickByCssSelector(
`${this.getSidebarSectionSelector(sectionName, true)} .euiAccordion__iconButton`
);
}
public async clickFieldListItem(field: string) {
return await this.testSubjects.click(`field-${field}`);
await this.testSubjects.click(`field-${field}`);
await this.retry.waitFor('popover is open', async () => {
return Boolean(await this.find.byCssSelector('[data-popover-open="true"]'));
});
}
public async clickFieldSort(field: string, text = 'Sort New-Old') {
@ -455,11 +510,16 @@ export class DiscoverPageObject extends FtrService {
}
public async clickFieldListItemAdd(field: string) {
await this.waitUntilSidebarHasLoaded();
// a filter check may make sense here, but it should be properly handled to make
// it work with the _score and _source fields as well
if (await this.isFieldSelected(field)) {
return;
}
if (['_score', '_id', '_index'].includes(field)) {
await this.toggleSidebarSection('meta'); // expand Meta section
}
await this.clickFieldListItemToggle(field);
const isLegacyDefault = await this.useLegacyTable();
if (isLegacyDefault) {
@ -474,16 +534,18 @@ export class DiscoverPageObject extends FtrService {
}
public async isFieldSelected(field: string) {
if (!(await this.testSubjects.exists('fieldList-selected'))) {
if (!(await this.testSubjects.exists('fieldListGroupedSelectedFields'))) {
return false;
}
const selectedList = await this.testSubjects.find('fieldList-selected');
const selectedList = await this.testSubjects.find('fieldListGroupedSelectedFields');
return await this.testSubjects.descendantExists(`field-${field}`, selectedList);
}
public async clickFieldListItemRemove(field: string) {
await this.waitUntilSidebarHasLoaded();
if (
!(await this.testSubjects.exists('fieldList-selected')) ||
!(await this.testSubjects.exists('fieldListGroupedSelectedFields')) ||
!(await this.isFieldSelected(field))
) {
return;
@ -493,6 +555,8 @@ export class DiscoverPageObject extends FtrService {
}
public async clickFieldListItemVisualize(fieldName: string) {
await this.waitUntilSidebarHasLoaded();
const field = await this.testSubjects.find(`field-${fieldName}-showDetails`);
const isActive = await field.elementHasClass('kbnFieldButton-isActive');

View file

@ -279,8 +279,6 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
},
});
const fieldsExistenceReader = useExistingFieldsReader();
const fieldsExistenceStatus =
fieldsExistenceReader.getFieldsExistenceStatus(currentIndexPatternId);
const visualizeGeoFieldTrigger = uiActions.getTrigger(VISUALIZE_GEO_FIELD_TRIGGER);
const allFields = useMemo(() => {
@ -326,7 +324,6 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
[localState]
);
const hasFilters = Boolean(filters.length);
const onOverrideFieldGroupDetails = useCallback(
(groupName) => {
if (groupName === FieldsGroupNames.AvailableFields) {
@ -342,25 +339,20 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
defaultMessage:
'Drag and drop available fields to the workspace and create visualizations. To change the available fields, select a different data view, edit your queries, or use a different time range. Some field types cannot be visualized in Lens, including full text and geographic fields.',
}),
isAffectedByGlobalFilter: hasFilters,
};
}
if (groupName === FieldsGroupNames.SelectedFields) {
return {
isAffectedByGlobalFilter: hasFilters,
};
}
},
[core.uiSettings, hasFilters]
[core.uiSettings]
);
const { fieldGroups } = useGroupedFields<IndexPatternField>({
const fieldListGroupedProps = useGroupedFields<IndexPatternField>({
dataViewId: currentIndexPatternId,
allFields,
services: {
dataViews,
},
fieldsExistenceReader,
isAffectedByGlobalFilter: Boolean(filters.length),
onFilterField,
onSupportedFieldFilter,
onSelectedFieldFilter,
@ -616,9 +608,7 @@ export const InnerFormBasedDataPanel = function InnerFormBasedDataPanel({
</EuiFlexItem>
<EuiFlexItem>
<FieldListGrouped<IndexPatternField>
fieldGroups={fieldGroups}
fieldsExistenceStatus={fieldsExistenceStatus}
fieldsExistInIndex={!!allFields.length}
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
data-test-subj="lnsIndexPattern"

View file

@ -239,9 +239,9 @@ export const InnerFieldItem = function InnerFieldItem(props: FieldItemProps) {
field: value.field.name,
},
})
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceDisabled', {
: i18n.translate('xpack.lens.indexPattern.moveToWorkspaceNotAvailable', {
defaultMessage:
"This field can't be added to the workspace automatically. You can still use it directly in the configuration panel.",
'To visualize this field, please add it directly to the desired layer. Adding this field to the workspace is not supported based on your current configuration.',
});
return (
@ -382,7 +382,7 @@ function FieldItemPopoverContents(
data-test-subj={`lnsFieldListPanel-exploreInDiscover-${dataViewField.name}`}
>
{i18n.translate('xpack.lens.indexPattern.fieldExploreInDiscover', {
defaultMessage: 'Explore values in Discover',
defaultMessage: 'Explore in Discover',
})}
</EuiButton>
</EuiPopoverFooter>

View file

@ -17,7 +17,6 @@ import { isOfAggregateQueryType } from '@kbn/es-query';
import { DatatableColumn, ExpressionsStart } from '@kbn/expressions-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import {
ExistenceFetchStatus,
FieldListGrouped,
FieldListGroupedProps,
FieldsGroupNames,
@ -108,9 +107,9 @@ export function TextBasedDataPanel({
}
}, []);
const { fieldGroups } = useGroupedFields<DatatableColumn>({
const fieldListGroupedProps = useGroupedFields<DatatableColumn>({
dataViewId: null,
allFields: fieldList,
allFields: dataHasLoaded ? fieldList : null,
services: {
dataViews,
},
@ -195,11 +194,7 @@ export function TextBasedDataPanel({
</EuiFlexItem>
<EuiFlexItem>
<FieldListGrouped<DatatableColumn>
fieldGroups={fieldGroups}
fieldsExistenceStatus={
dataHasLoaded ? ExistenceFetchStatus.succeeded : ExistenceFetchStatus.unknown
}
fieldsExistInIndex={Boolean(fieldList.length)}
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
screenReaderDescriptionForSearchInputId={fieldSearchDescriptionId}
data-test-subj="lnsTextBasedLanguages"

View file

@ -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.",

View file

@ -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": "移動平均はデータ全体でウィンドウをスライドし、平均値を表示します。移動平均は日付ヒストグラムでのみサポートされています。",

View file

@ -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": "移动平均值在数据上滑动时间窗并显示平均值。仅日期直方图支持移动平均值。",

View file

@ -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({