[UnifiedFieldList][Discover] Create a high level unified field list building block (#160397)

- Closes https://github.com/elastic/kibana/issues/145162 
- Closes https://github.com/elastic/kibana/issues/147884

## Summary

This PR creates a wrapper/container component (building block) for
unified field list subcomponents:

93acc6f707/packages/kbn-unified-field-list/README.md (L5)

Available customization options are listed here:
93acc6f707/packages/kbn-unified-field-list/src/types.ts (L116)

It's now integrated [into
Discover](93acc6f707/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx (L373))
and [into example
plugin](93acc6f707/examples/unified_field_list_examples/public/field_list_sidebar.tsx (L84)).
Usage of unified field list subcomponents and hooks stays unchanged in
Lens plugin as it requires more complex customization (for example Lens
uses IndexPattern/IndexPatternField types instead of data view types).

Also this PR allows to disable multifields grouping and select a variant
(responsive, list only, button only) via
`UnifiedFieldListSidebarContainer` properties.

There should no visual changes on Discover and Lens pages. Unified Field
List Examples plugin will get the same sidebar UI as it's on Discover.

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [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: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2023-07-10 12:18:40 +02:00 committed by GitHub
parent d8c8b7b0f0
commit ea53763028
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1799 additions and 1528 deletions

View file

@ -17,7 +17,8 @@
"dataViews",
"dataViewFieldEditor",
"charts",
"fieldFormats"
"fieldFormats",
"uiActions"
]
}
}

View file

@ -9,6 +9,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nProvider } from '@kbn/i18n-react';
import { CoreThemeProvider } from '@kbn/core-theme-browser-internal';
import type { AppMountParameters, CoreStart } from '@kbn/core/public';
import { AppPluginStartDependencies } from './types';
import { UnifiedFieldListExampleApp } from './example_app';
@ -16,17 +17,18 @@ import { UnifiedFieldListExampleApp } from './example_app';
export const renderApp = (
core: CoreStart,
deps: AppPluginStartDependencies,
{ element }: AppMountParameters
{ element, theme$ }: AppMountParameters
) => {
ReactDOM.render(
<I18nProvider>
<UnifiedFieldListExampleApp
services={{
core,
uiSettings: core.uiSettings,
...deps,
}}
/>
<CoreThemeProvider theme$={theme$}>
<UnifiedFieldListExampleApp
services={{
core,
...deps,
}}
/>
</CoreThemeProvider>
</I18nProvider>,
element
);

View file

@ -7,13 +7,11 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiPageBody,
EuiPageSidebar,
EuiTitle,
EuiEmptyPrompt,
EuiLoadingLogo,
@ -38,7 +36,7 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
const [dataView, setDataView] = useState<DataView | null>();
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const onAddFieldToWorkplace = useCallback(
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {
setSelectedFieldNames((names) => [...names, field.name]);
},
@ -124,20 +122,13 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
<RootDragDropProvider>
<EuiFlexGroup direction="row" alignItems="stretch">
<EuiFlexItem grow={false}>
<EuiPageSidebar
css={css`
flex: 1;
width: 320px;
`}
>
<FieldListSidebar
services={services}
dataView={dataView}
selectedFieldNames={selectedFieldNames}
onAddFieldToWorkplace={onAddFieldToWorkplace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
/>
</EuiPageSidebar>
<FieldListSidebar
services={services}
dataView={dataView}
selectedFieldNames={selectedFieldNames}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
/>
</EuiFlexItem>
<EuiFlexItem>
<ExampleDropZone onDropField={onDropFieldToWorkplace} />

View file

@ -1,225 +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.
*/
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/common';
import { DragDrop } from '@kbn/dom-drag-drop';
import {
AddFieldFilterHandler,
FieldItemButton,
FieldItemButtonProps,
FieldPopover,
FieldPopoverHeader,
FieldsGroupNames,
FieldStats,
hasQuerySubscriberData,
RenderFieldItemParams,
useQuerySubscriber,
} from '@kbn/unified-field-list';
import { generateFilters } from '@kbn/data-plugin/public';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { AppPluginStartDependencies } from './types';
export interface FieldListItemProps extends RenderFieldItemParams<DataViewField> {
dataView: DataView;
services: AppPluginStartDependencies & {
core: CoreStart;
uiSettings: CoreStart['uiSettings'];
};
isSelected: boolean;
onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace'];
onRefreshFields: () => void;
}
export function FieldListItem({
dataView,
services,
isSelected,
field,
fieldSearchHighlight,
groupIndex,
groupName,
itemIndex,
hideDetails,
onRefreshFields,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
}: FieldListItemProps) {
const { dataViewFieldEditor, data } = services;
const querySubscriberResult = useQuerySubscriber({ data, listenToSearchSessionUpdates: false }); // this example app does not use search sessions
const filterManager = data?.query?.filterManager;
const [infoIsOpen, setOpen] = useState(false);
const togglePopover = useCallback(() => {
setOpen((value) => !value);
}, [setOpen]);
const closePopover = useCallback(() => {
setOpen(false);
}, [setOpen]);
const closeFieldEditor = useRef<() => void | undefined>();
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const value = useMemo(
() => ({
id: field.name,
humanData: { label: field.displayName || field.name },
}),
[field]
);
const order = useMemo(() => [0, groupIndex, itemIndex], [groupIndex, itemIndex]);
const addFilterAndClose: AddFieldFilterHandler | undefined = useMemo(
() =>
filterManager && dataView
? (clickedField, values, operation) => {
closePopover();
const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
dataView
);
filterManager.addFilters(newFilters);
}
: undefined,
[dataView, filterManager, closePopover]
);
const editFieldAndClose = useMemo(
() =>
dataView
? (fieldName?: string) => {
const ref = dataViewFieldEditor.openEditor({
ctx: {
dataView,
},
fieldName,
onSave: async () => {
onRefreshFields();
},
});
if (setFieldEditorRef) {
setFieldEditorRef(ref);
}
closePopover();
}
: undefined,
[dataViewFieldEditor, dataView, setFieldEditorRef, closePopover, onRefreshFields]
);
const removeFieldAndClose = useMemo(
() =>
dataView
? async (fieldName: string) => {
const ref = dataViewFieldEditor.openDeleteModal({
ctx: {
dataView,
},
fieldName,
onDelete: async () => {
onRefreshFields();
},
});
if (setFieldEditorRef) {
setFieldEditorRef(ref);
}
closePopover();
}
: undefined,
[dataView, setFieldEditorRef, closePopover, dataViewFieldEditor, onRefreshFields]
);
useEffect(() => {
const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
}
};
return () => {
// Make sure to close the editor when unmounting
cleanup();
};
}, []);
return (
<li>
<FieldPopover
isOpen={infoIsOpen}
closePopover={closePopover}
button={
<DragDrop
draggable
order={order}
value={value}
dataTestSubj={`fieldListPanelField-${field.name}`}
onDragStart={closePopover}
>
<FieldItemButton
field={field}
fieldSearchHighlight={fieldSearchHighlight}
size="xs"
isActive={infoIsOpen}
isEmpty={groupName === FieldsGroupNames.EmptyFields}
isSelected={isSelected}
onClick={togglePopover}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
/>
</DragDrop>
}
renderHeader={() => {
return (
<FieldPopoverHeader
field={field}
closePopover={closePopover}
onAddFieldToWorkspace={!isSelected ? onAddFieldToWorkspace : undefined}
onAddFilter={addFilterAndClose}
onEditField={editFieldAndClose}
onDeleteField={removeFieldAndClose}
/>
);
}}
renderContent={
!hideDetails
? () => (
<>
{hasQuerySubscriberData(querySubscriberResult) && (
<FieldStats
services={services}
query={querySubscriberResult.query}
filters={querySubscriberResult.filters}
fromDate={querySubscriberResult.fromDate}
toDate={querySubscriberResult.toDate}
dataViewOrDataViewId={dataView}
onAddFilter={addFilterAndClose}
field={field}
/>
)}
</>
)
: undefined
}
/>
</li>
);
}

View file

@ -14,114 +14,86 @@
* Side Public License, v 1.
*/
import React, { useCallback, useContext, useMemo } from 'react';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import React, { useCallback, useContext, useMemo, useRef } from 'react';
import type { DataView } from '@kbn/data-views-plugin/public';
import { generateFilters } from '@kbn/data-plugin/public';
import { ChildDragDropProvider, DragContext } from '@kbn/dom-drag-drop';
import {
FieldList,
FieldListFilters,
FieldListGrouped,
FieldListGroupedProps,
FieldsGroupNames,
useExistingFieldsFetcher,
useGroupedFields,
useQuerySubscriber,
UnifiedFieldListSidebarContainer,
type UnifiedFieldListSidebarContainerProps,
type UnifiedFieldListSidebarContainerApi,
type AddFieldFilterHandler,
} from '@kbn/unified-field-list';
import { FieldListItem, FieldListItemProps } from './field_list_item';
import { type CoreStart } from '@kbn/core-lifecycle-browser';
import { PLUGIN_ID } from '../common';
import { type AppPluginStartDependencies } from './types';
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
return {
originatingApp: PLUGIN_ID,
localStorageKeyPrefix: 'examples',
timeRangeUpdatesType: 'timefilter',
disablePopularFields: true,
};
};
export interface FieldListSidebarProps {
dataView: DataView;
selectedFieldNames: string[];
services: FieldListItemProps['services'];
onAddFieldToWorkplace: FieldListItemProps['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: FieldListItemProps['onRemoveFieldFromWorkspace'];
services: AppPluginStartDependencies & {
core: CoreStart;
};
onAddFieldToWorkspace: UnifiedFieldListSidebarContainerProps['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: UnifiedFieldListSidebarContainerProps['onRemoveFieldFromWorkspace'];
}
export const FieldListSidebar: React.FC<FieldListSidebarProps> = ({
dataView,
selectedFieldNames,
services,
onAddFieldToWorkplace,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
}) => {
const dragDropContext = useContext(DragContext);
const allFields = dataView.fields;
const activeDataViews = useMemo(() => [dataView], [dataView]);
const querySubscriberResult = useQuerySubscriber({
data: services.data,
listenToSearchSessionUpdates: false, // this example app does not use search sessions
});
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
const filterManager = services.data?.query?.filterManager;
const onSelectedFieldFilter = useCallback(
(field: DataViewField) => {
return selectedFieldNames.includes(field.name);
},
[selectedFieldNames]
const onAddFilter: AddFieldFilterHandler | undefined = useMemo(
() =>
filterManager && dataView
? (clickedField, values, operation) => {
const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
dataView
);
filterManager.addFilters(newFilters);
}
: undefined,
[dataView, filterManager]
);
const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({
dataViews: activeDataViews, // if you need field existence info for more than one data view, you can specify it here
query: querySubscriberResult.query,
filters: querySubscriberResult.filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services,
});
const { fieldListFiltersProps, fieldListGroupedProps } = useGroupedFields({
dataViewId: dataView.id ?? null,
allFields,
services,
isAffectedByGlobalFilter: Boolean(querySubscriberResult.filters?.length),
onSupportedFieldFilter,
onSelectedFieldFilter,
});
const onRefreshFields = useCallback(() => {
refetchFieldsExistenceInfo();
}, [refetchFieldsExistenceInfo]);
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
(params) => (
<FieldListItem
dataView={dataView}
services={services}
isSelected={
params.groupName === FieldsGroupNames.SelectedFields ||
selectedFieldNames.includes(params.field.name)
}
onRefreshFields={onRefreshFields}
onAddFieldToWorkspace={onAddFieldToWorkplace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
{...params}
/>
),
[
dataView,
services,
onRefreshFields,
selectedFieldNames,
onAddFieldToWorkplace,
onRemoveFieldFromWorkspace,
]
);
const onFieldEdited = useCallback(async () => {
unifiedFieldListContainerRef.current?.refetchFieldsExistenceInfo();
}, [unifiedFieldListContainerRef]);
return (
<ChildDragDropProvider {...dragDropContext}>
<FieldList
isProcessing={isProcessing}
prepend={<FieldListFilters {...fieldListFiltersProps} />}
>
<FieldListGrouped
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
localStorageKeyPrefix="examples"
/>
</FieldList>
<UnifiedFieldListSidebarContainer
ref={unifiedFieldListContainerRef}
variant="responsive"
getCreationOptions={getCreationOptions}
services={services}
dataView={dataView}
allFields={dataView.fields}
workspaceSelectedFieldNames={selectedFieldNames}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
/>
</ChildDragDropProvider>
);
};
function onSupportedFieldFilter(field: DataViewField): boolean {
return field.name !== '_source';
}

View file

@ -14,6 +14,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface UnifiedFieldListExamplesPluginSetup {}
@ -32,4 +33,5 @@ export interface AppPluginStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart;
charts: ChartsPluginStart;
fieldFormats: FieldFormatsStart;
uiActions: UiActionsStart;
}

View file

@ -28,5 +28,7 @@
"@kbn/field-formats-plugin",
"@kbn/data-view-field-editor-plugin",
"@kbn/unified-field-list",
"@kbn/core-theme-browser-internal",
"@kbn/ui-actions-plugin",
]
}

View file

@ -1,5 +1,5 @@
{
"type": "shared-browser",
"type": "shared-common",
"id": "@kbn/dom-drag-drop",
"owner": [
"@elastic/kibana-visualizations",

View file

@ -2,9 +2,60 @@
This Kibana package contains components and services for field list UI (as in fields sidebar on Discover and Lens pages).
## UnifiedFieldListSidebarContainer - building block
An example of its usage can be found in Kibana example plugin [examples/unified_field_list_examples](/examples/unified_field_list_examples).
Configure the field list:
```
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
return {
originatingApp: PLUGIN_ID,
localStorageKeyPrefix: 'examples',
timeRangeUpdatesType: 'timefilter',
disablePopularFields: true,
... // more customization option are available
};
};
```
Define a ref for accessing API if necessary:
```
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
```
where `unifiedFieldListContainerRef.current` provides the following API:
```
refetchFieldsExistenceInfo: ExistingFieldsFetcher['refetchFieldsExistenceInfo'];
closeFieldListFlyout: () => void;
// no user permission or missing dataViewFieldEditor service will result in `undefined` API methods
createField: undefined | (() => void);
editField: undefined | ((fieldName: string) => void);
deleteField: undefined | ((fieldName: string) => void);
```
Include the building block into your application:
```
<UnifiedFieldListSidebarContainer
ref={unifiedFieldListContainerRef}
// `responsive` is to show the list for desktop view and a button which triggers a flyout with the list for mobile view
variant="responsive" // can be also `list-always` and `button-and-flyout-always`
getCreationOptions={getCreationOptions}
services={services}
dataView={dataView}
allFields={dataView.fields}
workspaceSelectedFieldNames={selectedFieldNames}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
/>
```
---
## Field Stats and Field Popover Components
## Field Stats and Field Popover Components - can be also used as a building block
* `<FieldStats .../>` - loads and renders stats (Top values, Distribution) for a data view field.
@ -53,7 +104,7 @@ These components can be combined and customized as the following:
/>
```
## Field List components
## Field List subcomponents (for low level customization, otherwise consider using UnifiedFieldListSidebarContainer)
* `<FieldList .../>` - a top-level component to render field filters and field list sections.
@ -139,12 +190,6 @@ const { hasFieldData } = useExistingFieldsReader();
const hasData = hasFieldData(currentDataViewId, fieldName) // returns a boolean
```
## Server APIs
* `/internal/unified_field_list/field_stats` - returns the loaded field stats (except for Ad-hoc data views)
* `/internal/unified_field_list/existing_fields/{dataViewId}` - returns the loaded existing fields (except for Ad-hoc data views)
## Development
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment.

View file

@ -0,0 +1,42 @@
/*
* 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 { UnifiedFieldListSidebarContainerProps } from '../src/containers/unified_field_list_sidebar';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { calculateBounds } from '@kbn/data-plugin/common';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { coreMock } from '@kbn/core/public/mocks';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { indexPatternFieldEditorPluginMock as dataViewFieldEditorPluginMock } from '@kbn/data-view-field-editor-plugin/public/mocks';
export const getServicesMock = (): UnifiedFieldListSidebarContainerProps['services'] => {
const mockedServices: UnifiedFieldListSidebarContainerProps['services'] = {
data: dataPluginMock.createStartContract(),
dataViews: dataViewPluginMocks.createStartContract(),
fieldFormats: fieldFormatsServiceMock.createStartContract(),
charts: chartPluginMock.createSetupContract(),
core: coreMock.createStart(),
uiActions: uiActionsPluginMock.createStartContract(),
dataViewFieldEditor: dataViewFieldEditorPluginMock.createStartContract(),
};
mockedServices.data.query.timefilter.timefilter.getTime = jest.fn(() => {
return { from: 'now-15m', to: 'now' };
});
mockedServices.data.query.timefilter.timefilter.calculateBounds = jest.fn(calculateBounds);
mockedServices.data.query.getState = jest.fn(() => ({
query: { query: '', language: 'lucene' },
filters: [],
}));
return mockedServices;
};

View file

@ -52,6 +52,7 @@ export type {
FieldListItem,
GetCustomFieldType,
RenderFieldItemParams,
SearchMode,
} from './src/types';
export { ExistenceFetchStatus, FieldsGroupNames } from './src/types';
@ -80,6 +81,7 @@ export {
export {
useQuerySubscriber,
hasQuerySubscriberData,
getSearchMode,
type QuerySubscriberResult,
type QuerySubscriberParams,
} from './src/hooks/use_query_subscriber';
@ -91,3 +93,9 @@ export {
getFieldType,
getFieldIconType,
} from './src/utils/field_types';
export {
UnifiedFieldListSidebarContainer,
type UnifiedFieldListSidebarContainerApi,
type UnifiedFieldListSidebarContainerProps,
} from './src/containers/unified_field_list_sidebar';

View file

@ -0,0 +1,23 @@
/*
* 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 {
UnifiedFieldListSidebarContainerCreationOptions,
UnifiedFieldListSidebarContainerStateService,
} from '../../types';
export const createStateService = ({
options,
}: {
options: UnifiedFieldListSidebarContainerCreationOptions;
}): UnifiedFieldListSidebarContainerStateService => {
// bootstrapping a simple service for extending it later if necessary
return {
creationOptions: options,
};
};

View file

@ -11,16 +11,14 @@ import { EuiButtonIcon, EuiPopover, EuiProgress } from '@elastic/eui';
import React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl } from '@kbn/test-jest-helpers';
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 { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FieldItemButton } from '@kbn/unified-field-list';
import { getServicesMock } from '../../../__mocks__/services.mock';
import { UnifiedFieldListItem, UnifiedFieldListItemProps } from './field_list_item';
import { FieldItemButton } from '../../components/field_item_button';
import { createStateService } from '../services/state_service';
jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
jest.mock('../../services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({
totalDocuments: 1624,
sampledDocuments: 1624,
@ -40,22 +38,14 @@ jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
}),
}));
jest.mock('../../../../kibana_services', () => ({
getUiActions: jest.fn(() => {
return {
getTriggerCompatibleActions: jest.fn(() => []),
};
}),
}));
async function getComponent({
selected = false,
field,
onAddFilterExists = true,
canFilter = true,
}: {
selected?: boolean;
field?: DataViewField;
onAddFilterExists?: boolean;
canFilter?: boolean;
}) {
const finalField =
field ??
@ -72,62 +62,45 @@ async function getComponent({
const dataView = stubDataView;
dataView.toSpec = () => ({});
const props: DiscoverFieldProps = {
const stateService = createStateService({
options: {
originatingApp: 'test',
},
});
const props: UnifiedFieldListItemProps = {
services: getServicesMock(),
stateService,
searchMode: 'documents',
dataView: stubDataView,
field: finalField,
...(onAddFilterExists && { onAddFilter: jest.fn() }),
onAddField: jest.fn(),
...(canFilter && { onAddFilter: jest.fn() }),
onAddFieldToWorkspace: jest.fn(),
onRemoveFieldFromWorkspace: jest.fn(),
onEditField: jest.fn(),
onRemoveField: jest.fn(),
isSelected: selected,
isEmpty: false,
groupIndex: 1,
itemIndex: 0,
contextualFields: [],
workspaceSelectedFieldNames: [],
};
const services = {
...createDiscoverServicesMock(),
capabilities: {
visualize: {
show: true,
},
},
uiSettings: {
get: (key: string) => {
if (key === 'fields:popularLimit') {
return 5;
}
},
},
};
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appState;
appStateContainer.set({
query: { query: '', language: 'lucene' },
filters: [],
});
const comp = await mountWithIntl(
<KibanaContextProvider services={services}>
<DiscoverAppStateProvider value={appStateContainer}>
<DiscoverField {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
);
const comp = await mountWithIntl(<UnifiedFieldListItem {...props} />);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
return { comp, props };
}
describe('discover sidebar field', function () {
describe('UnifiedFieldListItem', function () {
it('should allow selecting fields', async function () {
const { comp, props } = await getComponent({});
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
expect(props.onAddField).toHaveBeenCalledWith('bytes');
expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(props.field);
});
it('should allow deselecting fields', async function () {
const { comp, props } = await getComponent({ selected: true });
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
expect(props.onRemoveField).toHaveBeenCalledWith('bytes');
expect(props.onRemoveFieldFromWorkspace).toHaveBeenCalledWith(props.field);
});
it('displays warning for conflicting fields', async function () {
const field = new DataViewField({
@ -157,7 +130,7 @@ describe('discover sidebar field', function () {
const { comp } = await getComponent({
selected: true,
field,
onAddFilterExists: false,
canFilter: false,
});
expect(comp.find(FieldItemButton).prop('onClick')).toBeUndefined();
@ -171,7 +144,7 @@ describe('discover sidebar field', function () {
searchable: true,
});
const { comp } = await getComponent({ field, onAddFilterExists: true });
const { comp } = await getComponent({ field, canFilter: true });
await act(async () => {
const fieldItem = findTestSubject(comp, 'field-machine.os.raw-showDetails');
@ -186,13 +159,13 @@ describe('discover sidebar field', function () {
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
expect(findTestSubject(comp, 'dscFieldStats-title').text()).toBe('Top values');
expect(findTestSubject(comp, 'dscFieldStats-topValues-bucket')).toHaveLength(2);
expect(
findTestSubject(comp, 'dscFieldStats-topValues-formattedFieldValue').first().text()
).toBe('osx');
expect(findTestSubject(comp, 'fieldStats-title').text()).toBe('Top values');
expect(findTestSubject(comp, 'fieldStats-topValues-bucket')).toHaveLength(2);
expect(findTestSubject(comp, 'fieldStats-topValues-formattedFieldValue').first().text()).toBe(
'osx'
);
expect(comp.find(EuiProgress)).toHaveLength(2);
expect(findTestSubject(comp, 'dscFieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
expect(findTestSubject(comp, 'fieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
});
it('should include popover actions', async function () {
const field = new DataViewField({
@ -203,7 +176,7 @@ describe('discover sidebar field', function () {
searchable: true,
});
const { comp, props } = await getComponent({ field, onAddFilterExists: true });
const { comp, props } = await getComponent({ field, canFilter: true });
await act(async () => {
const fieldItem = findTestSubject(comp, 'field-extension.keyword-showDetails');
@ -218,15 +191,13 @@ describe('discover sidebar field', function () {
comp.find('[data-test-subj="fieldPopoverHeader_addField-extension.keyword"]').exists()
).toBeTruthy();
expect(
comp
.find('[data-test-subj="discoverFieldListPanelAddExistFilter-extension.keyword"]')
.exists()
comp.find('[data-test-subj="fieldPopoverHeader_addExistsFilter-extension.keyword"]').exists()
).toBeTruthy();
expect(
comp.find('[data-test-subj="discoverFieldListPanelEdit-extension.keyword"]').exists()
comp.find('[data-test-subj="fieldPopoverHeader_editField-extension.keyword"]').exists()
).toBeTruthy();
expect(
comp.find('[data-test-subj="discoverFieldListPanelDelete-extension.keyword"]').exists()
comp.find('[data-test-subj="fieldPopoverHeader_deleteField-extension.keyword"]').exists()
).toBeFalsy();
await act(async () => {
@ -235,7 +206,7 @@ describe('discover sidebar field', function () {
await comp.update();
});
expect(props.onAddField).toHaveBeenCalledWith('extension.keyword');
expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(field);
await comp.update();
@ -253,7 +224,7 @@ describe('discover sidebar field', function () {
const { comp } = await getComponent({
field,
onAddFilterExists: true,
canFilter: true,
selected: true,
});

View file

@ -6,41 +6,47 @@
* Side Public License, v 1.
*/
import './discover_field.scss';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import { DragDrop } from '@kbn/dom-drag-drop';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { SearchMode } from '../../types';
import { FieldItemButton, type FieldItemButtonProps } from '../../components/field_item_button';
import {
FieldItemButton,
type FieldItemButtonProps,
FieldPopover,
FieldPopoverHeader,
FieldPopoverHeaderProps,
type FieldPopoverHeaderProps,
FieldPopoverFooter,
} from '@kbn/unified-field-list';
import { DragDrop } from '@kbn/dom-drag-drop';
import { DiscoverFieldStats } from './discover_field_stats';
import { PLUGIN_ID } from '../../../../../common';
import { getUiActions } from '../../../../kibana_services';
type FieldPopoverFooterProps,
} from '../../components/field_popover';
import {
UnifiedFieldListItemStats,
type UnifiedFieldListItemStatsProps,
} from './field_list_item_stats';
import type {
UnifiedFieldListSidebarContainerStateService,
AddFieldFilterHandler,
} from '../../types';
interface GetCommonFieldItemButtonPropsParams {
stateService: UnifiedFieldListSidebarContainerStateService;
field: DataViewField;
isSelected: boolean;
toggleDisplay: (field: DataViewField, isSelected?: boolean) => void;
}
function getCommonFieldItemButtonProps({
stateService,
field,
isSelected,
toggleDisplay,
}: GetCommonFieldItemButtonPropsParams): {
field: FieldItemButtonProps<DataViewField>['field'];
isSelected: FieldItemButtonProps<DataViewField>['isSelected'];
buttonAddFieldToWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
buttonRemoveFieldFromWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
buttonAddFieldToWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
buttonRemoveFieldFromWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace'];
} {
@ -49,33 +55,27 @@ function getCommonFieldItemButtonProps({
return {
field,
isSelected,
buttonAddFieldToWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
}),
},
buttonRemoveFieldFromWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
}),
},
buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
buttonRemoveFieldFromWorkspaceProps:
stateService.creationOptions.buttonRemoveFieldFromWorkspaceProps,
onAddFieldToWorkspace: handler,
onRemoveFieldFromWorkspace: handler,
};
}
interface MultiFieldsProps {
multiFields: NonNullable<DiscoverFieldProps['multiFields']>;
stateService: UnifiedFieldListSidebarContainerStateService;
multiFields: NonNullable<UnifiedFieldListItemProps['multiFields']>;
toggleDisplay: (field: DataViewField) => void;
alwaysShowActionButton: boolean;
}
const MultiFields: React.FC<MultiFieldsProps> = memo(
({ multiFields, toggleDisplay, alwaysShowActionButton }) => (
({ stateService, multiFields, toggleDisplay, alwaysShowActionButton }) => (
<React.Fragment>
<EuiTitle size="xxxs">
<h5>
{i18n.translate('discover.fieldChooser.discoverField.multiFields', {
{i18n.translate('unifiedFieldList.fieldListItem.multiFields', {
defaultMessage: 'Multi fields',
})}
</h5>
@ -85,13 +85,13 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
<FieldItemButton
key={entry.field.name}
size="xs"
className="dscSidebarItem dscSidebarItem--multi"
flush="both"
isEmpty={false}
isActive={false}
shouldAlwaysShowAction={alwaysShowActionButton}
onClick={undefined}
{...getCommonFieldItemButtonProps({
stateService,
field: entry.field,
isSelected: entry.isSelected,
toggleDisplay,
@ -102,7 +102,22 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
)
);
export interface DiscoverFieldProps {
export interface UnifiedFieldListItemProps {
/**
* Service for managing the state
*/
stateService: UnifiedFieldListSidebarContainerStateService;
/**
* Required services
*/
services: UnifiedFieldListItemStatsProps['services'] & {
uiActions?: FieldPopoverFooterProps['uiActions'];
};
/**
* Current search mode
*/
searchMode: SearchMode | undefined;
/**
* Determines whether add/remove button is displayed not only when focused
*/
@ -118,16 +133,16 @@ export interface DiscoverFieldProps {
/**
* Callback to add/select the field
*/
onAddField: (fieldName: string) => void;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: DataViewField | string, value: unknown, type: '+' | '-') => void;
onAddFieldToWorkspace: (field: DataViewField) => void;
/**
* Callback to remove a field column from the table
* @param fieldName
*/
onRemoveField: (fieldName: string) => void;
onRemoveFieldFromWorkspace: (field: DataViewField) => void;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: AddFieldFilterHandler;
/**
* Determines whether the field is empty
*/
@ -142,49 +157,48 @@ export interface DiscoverFieldProps {
* @param eventName
*/
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
/**
* Multi fields for the current field
*/
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
/**
* Callback to edit a field from data view
* @param fieldName name of the field to edit
*/
onEditField?: (fieldName: string) => void;
onEditField?: (fieldName?: string) => void;
/**
* Callback to delete a runtime field from data view
* @param fieldName name of the field to delete
*/
onDeleteField?: (fieldName: string) => void;
/**
* Columns
* Currently selected fields like table columns
*/
contextualFields: string[];
workspaceSelectedFieldNames?: string[];
/**
* Search by field name
*/
highlight?: string;
/**
* Group index in the field list
*/
groupIndex: number;
/**
* Item index in the field list
*/
itemIndex: number;
}
function DiscoverFieldComponent({
function UnifiedFieldListItemComponent({
stateService,
services,
searchMode,
alwaysShowActionButton = false,
field,
highlight,
dataView,
onAddField,
onRemoveField,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
onAddFilter,
isEmpty,
isSelected,
@ -192,12 +206,11 @@ function DiscoverFieldComponent({
multiFields,
onEditField,
onDeleteField,
contextualFields,
workspaceSelectedFieldNames,
groupIndex,
itemIndex,
}: DiscoverFieldProps) {
}: UnifiedFieldListItemProps) {
const [infoIsOpen, setOpen] = useState(false);
const isDocumentRecord = !!onAddFilter;
const addFilterAndClosePopover: typeof onAddFilter | undefined = useMemo(
() =>
@ -222,40 +235,41 @@ function DiscoverFieldComponent({
(f, isCurrentlySelected) => {
closePopover();
if (isCurrentlySelected) {
onRemoveField(f.name);
onRemoveFieldFromWorkspace(f);
} else {
onAddField(f.name);
onAddFieldToWorkspace(f);
}
},
[onAddField, onRemoveField, closePopover]
[onAddFieldToWorkspace, onRemoveFieldFromWorkspace, closePopover]
);
const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]);
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(
() => ({
buttonAddFieldToWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
}),
},
buttonAddFilterProps: {
'data-test-subj': `discoverFieldListPanelAddExistFilter-${field.name}`,
},
buttonEditFieldProps: {
'data-test-subj': `discoverFieldListPanelEdit-${field.name}`,
},
buttonDeleteFieldProps: {
'data-test-subj': `discoverFieldListPanelDelete-${field.name}`,
},
}),
[field.name]
);
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(() => {
const dataTestSubjPrefix =
stateService.creationOptions.dataTestSubj?.fieldListItemPopoverHeaderDataTestSubjPrefix;
return {
buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
...(dataTestSubjPrefix && {
buttonAddFilterProps: {
'data-test-subj': `${dataTestSubjPrefix}AddExistFilter-${field.name}`,
},
buttonEditFieldProps: {
'data-test-subj': `${dataTestSubjPrefix}Edit-${field.name}`,
},
buttonDeleteFieldProps: {
'data-test-subj': `${dataTestSubjPrefix}Delete-${field.name}`,
},
}),
};
}, [field.name, stateService.creationOptions]);
const renderPopover = () => {
return (
<>
<DiscoverFieldStats
<UnifiedFieldListItemStats
stateService={stateService}
services={services}
field={field}
multiFields={multiFields}
dataView={dataView}
@ -266,6 +280,7 @@ function DiscoverFieldComponent({
<>
<EuiSpacer size="m" />
<MultiFields
stateService={stateService}
multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton}
toggleDisplay={toggleDisplay}
@ -273,16 +288,18 @@ function DiscoverFieldComponent({
</>
)}
<FieldPopoverFooter
field={field}
dataView={dataView}
multiFields={rawMultiFields}
trackUiMetric={trackUiMetric}
contextualFields={contextualFields}
originatingApp={PLUGIN_ID}
uiActions={getUiActions()}
closePopover={() => closePopover()}
/>
{!!services.uiActions && (
<FieldPopoverFooter
field={field}
dataView={dataView}
multiFields={rawMultiFields}
trackUiMetric={trackUiMetric}
contextualFields={workspaceSelectedFieldNames}
originatingApp={stateService.creationOptions.originatingApp}
uiActions={services.uiActions}
closePopover={() => closePopover()}
/>
)}
</>
);
};
@ -308,24 +325,28 @@ function DiscoverFieldComponent({
order={order}
value={value}
onDragStart={closePopover}
isDisabled={alwaysShowActionButton}
dataTestSubj={`dscFieldListPanelField-${field.name}`}
isDisabled={
alwaysShowActionButton || stateService.creationOptions.disableFieldListItemDragAndDrop
}
dataTestSubj={`${
stateService.creationOptions.dataTestSubj?.fieldListItemDndDataTestSubjPrefix ??
'unifiedFieldListItemDnD'
}-${field.name}`}
>
<FieldItemButton
size="xs"
fieldSearchHighlight={highlight}
className="dscSidebarItem"
isEmpty={isEmpty}
isActive={infoIsOpen}
flush={alwaysShowActionButton ? 'both' : undefined}
shouldAlwaysShowAction={alwaysShowActionButton}
onClick={field.type !== '_source' ? togglePopover : undefined}
{...getCommonFieldItemButtonProps({ field, isSelected, toggleDisplay })}
{...getCommonFieldItemButtonProps({ stateService, field, isSelected, toggleDisplay })}
/>
</DragDrop>
}
closePopover={closePopover}
data-test-subj="discoverFieldListPanelPopover"
data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemPopoverDataTestSubj}
renderHeader={() => (
<FieldPopoverHeader
field={field}
@ -337,9 +358,9 @@ function DiscoverFieldComponent({
{...customPopoverHeaderProps}
/>
)}
renderContent={isDocumentRecord ? renderPopover : undefined}
renderContent={searchMode === 'documents' ? renderPopover : undefined}
/>
);
}
export const DiscoverField = memo(DiscoverFieldComponent);
export const UnifiedFieldListItem = memo(UnifiedFieldListItemComponent);

View file

@ -7,26 +7,32 @@
*/
import React, { useMemo } from 'react';
import { FieldStats, FieldStatsProps } from '@kbn/unified-field-list/src/components/field_stats';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import {
useQuerySubscriber,
hasQuerySubscriberData,
} from '@kbn/unified-field-list/src/hooks/use_query_subscriber';
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
FieldStats,
type FieldStatsProps,
type FieldStatsServices,
} from '../../components/field_stats';
import { useQuerySubscriber, hasQuerySubscriberData } from '../../hooks/use_query_subscriber';
import type { UnifiedFieldListSidebarContainerStateService } from '../../types';
export interface DiscoverFieldStatsProps {
export interface UnifiedFieldListItemStatsProps {
stateService: UnifiedFieldListSidebarContainerStateService;
field: DataViewField;
services: Omit<FieldStatsServices, 'uiSettings'> & {
core: CoreStart;
};
dataView: DataView;
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
onAddFilter: FieldStatsProps['onAddFilter'];
}
export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
({ field, dataView, multiFields, onAddFilter }) => {
const services = useDiscoverServices();
export const UnifiedFieldListItemStats: React.FC<UnifiedFieldListItemStatsProps> = React.memo(
({ stateService, services, field, dataView, multiFields, onAddFilter }) => {
const querySubscriberResult = useQuerySubscriber({
data: services.data,
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
});
// prioritize an aggregatable multi field if available or take the parent field
const fieldForStats = useMemo(
@ -37,20 +43,31 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
[field, multiFields]
);
const statsServices: FieldStatsServices = useMemo(
() => ({
data: services.data,
dataViews: services.dataViews,
fieldFormats: services.fieldFormats,
charts: services.charts,
uiSettings: services.core.uiSettings,
}),
[services]
);
if (!hasQuerySubscriberData(querySubscriberResult)) {
return null;
}
return (
<FieldStats
services={services}
services={statsServices}
query={querySubscriberResult.query}
filters={querySubscriberResult.filters}
fromDate={querySubscriberResult.fromDate}
toDate={querySubscriberResult.toDate}
dataViewOrDataViewId={dataView}
field={fieldForStats}
data-test-subj="dscFieldStats"
data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemStatsDataTestSubj}
onAddFilter={onAddFilter}
/>
);

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { UnifiedFieldListItem, type UnifiedFieldListItemProps } from './field_list_item';

View file

@ -1,4 +1,4 @@
.dscSidebar {
.unifiedFieldListSidebar {
overflow: hidden;
margin: 0 !important;
flex-grow: 1;
@ -13,7 +13,7 @@
}
}
.dscSidebar__list {
.unifiedFieldListSidebar__list {
padding: $euiSizeS 0 $euiSizeS $euiSizeS;
@include euiBreakpoint('xs', 's') {
@ -21,20 +21,20 @@
}
}
.dscSidebar__group {
.unifiedFieldListSidebar__group {
height: 100%;
}
.dscSidebar__mobile {
.unifiedFieldListSidebar__mobile {
width: 100%;
padding: $euiSizeS $euiSizeS 0;
.dscSidebar__mobileBadge {
.unifiedFieldListSidebar__mobileBadge {
margin-left: $euiSizeS;
vertical-align: text-bottom;
}
}
.dscSidebar__flyoutHeader {
.unifiedFieldListSidebar__flyoutHeader {
align-items: center;
}

View file

@ -0,0 +1,351 @@
/*
* 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 './field_list_sidebar.scss';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPageSidebar } from '@elastic/eui';
import { type DataViewField } from '@kbn/data-views-plugin/public';
import { getDataViewFieldSubtypeMulti } from '@kbn/es-query/src/utils';
import { FieldList } from '../../components/field_list';
import { FieldListFilters } from '../../components/field_list_filters';
import { FieldListGrouped, type FieldListGroupedProps } from '../../components/field_list_grouped';
import { FieldsGroupNames } from '../../types';
import { GroupedFieldsParams, useGroupedFields } from '../../hooks/use_grouped_fields';
import { UnifiedFieldListItem, type UnifiedFieldListItemProps } from '../unified_field_list_item';
import {
getSelectedFields,
shouldShowField,
type SelectedFieldsResult,
INITIAL_SELECTED_FIELDS_RESULT,
} from './group_fields';
const FIELDS_LIMIT_SETTING = 'fields:popularLimit';
const SEARCH_FIELDS_FROM_SOURCE = 'discover:searchFieldsFromSource';
export type UnifiedFieldListSidebarCustomizableProps = Pick<
UnifiedFieldListItemProps,
| 'services'
| 'workspaceSelectedFieldNames'
| 'dataView'
| 'trackUiMetric'
| 'onAddFilter'
| 'onAddFieldToWorkspace'
| 'onRemoveFieldFromWorkspace'
> & {
/**
* All fields: fields from data view and unmapped fields or columns from text-based search
*/
allFields: DataViewField[] | null;
/**
* Whether to render the field list or not (we don't show it unless documents are loaded)
*/
showFieldList?: boolean;
/**
* Custom logic for determining which field is selected
*/
onSelectedFieldFilter?: GroupedFieldsParams<DataViewField>['onSelectedFieldFilter'];
};
interface UnifiedFieldListSidebarInternalProps {
/**
* Current search mode based on current query
*/
searchMode: UnifiedFieldListItemProps['searchMode'];
/**
* Service for managing the state
*/
stateService: UnifiedFieldListItemProps['stateService'];
/**
* Show loading instead of the field list if processing
*/
isProcessing: boolean;
/**
* Whether filters are applied
*/
isAffectedByGlobalFilter: boolean;
/**
* Custom element to render at the top
*/
prepend?: React.ReactNode;
/**
* Whether to make action buttons visible
*/
alwaysShowActionButton?: UnifiedFieldListItemProps['alwaysShowActionButton'];
/**
* Trigger a field editing
*/
onEditField: UnifiedFieldListItemProps['onEditField'] | undefined;
/**
* Trigger a field deletion
*/
onDeleteField: UnifiedFieldListItemProps['onDeleteField'] | undefined;
}
export type UnifiedFieldListSidebarProps = UnifiedFieldListSidebarCustomizableProps &
UnifiedFieldListSidebarInternalProps;
export const UnifiedFieldListSidebarComponent: React.FC<UnifiedFieldListSidebarProps> = ({
stateService,
searchMode,
services,
workspaceSelectedFieldNames,
isProcessing,
alwaysShowActionButton,
allFields,
dataView,
trackUiMetric,
showFieldList = true,
isAffectedByGlobalFilter,
prepend,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
onAddFilter,
onSelectedFieldFilter,
onEditField,
onDeleteField,
}) => {
const { dataViews, core } = services;
const useNewFieldsApi = useMemo(
() => !core.uiSettings.get(SEARCH_FIELDS_FROM_SOURCE),
[core.uiSettings]
);
const [selectedFieldsState, setSelectedFieldsState] = useState<SelectedFieldsResult>(
INITIAL_SELECTED_FIELDS_RESULT
);
const [multiFieldsMap, setMultiFieldsMap] = useState<
Map<string, Array<{ field: DataViewField; isSelected: boolean }>> | undefined
>(undefined);
useEffect(() => {
const result = getSelectedFields({
dataView,
workspaceSelectedFieldNames: onSelectedFieldFilter ? [] : workspaceSelectedFieldNames,
allFields,
searchMode,
});
setSelectedFieldsState(result);
}, [
dataView,
workspaceSelectedFieldNames,
setSelectedFieldsState,
allFields,
searchMode,
onSelectedFieldFilter,
]);
useEffect(() => {
if (
searchMode !== 'documents' ||
!useNewFieldsApi ||
stateService.creationOptions.disableMultiFieldsGroupingByParent
) {
setMultiFieldsMap(undefined); // we don't have to calculate multifields in this case
} else {
setMultiFieldsMap(calculateMultiFields(allFields, selectedFieldsState.selectedFieldsMap));
}
}, [
stateService.creationOptions.disableMultiFieldsGroupingByParent,
selectedFieldsState.selectedFieldsMap,
allFields,
useNewFieldsApi,
setMultiFieldsMap,
searchMode,
]);
const popularFieldsLimit = useMemo(
() => core.uiSettings.get(FIELDS_LIMIT_SETTING),
[core.uiSettings]
);
const onSupportedFieldFilter: GroupedFieldsParams<DataViewField>['onSupportedFieldFilter'] =
useCallback(
(field) => {
return shouldShowField(
field,
searchMode,
stateService.creationOptions.disableMultiFieldsGroupingByParent
);
},
[searchMode, stateService.creationOptions.disableMultiFieldsGroupingByParent]
);
const { fieldListFiltersProps, fieldListGroupedProps } = useGroupedFields<DataViewField>({
dataViewId: (searchMode === 'documents' && dataView?.id) || null, // passing `null` for text-based queries
allFields,
popularFieldsLimit:
searchMode !== 'documents' || stateService.creationOptions.disablePopularFields
? 0
: popularFieldsLimit,
isAffectedByGlobalFilter,
services: {
dataViews,
core,
},
sortedSelectedFields: onSelectedFieldFilter ? undefined : selectedFieldsState.selectedFields,
onSelectedFieldFilter,
onSupportedFieldFilter:
stateService.creationOptions.onSupportedFieldFilter ?? onSupportedFieldFilter,
onOverrideFieldGroupDetails: stateService.creationOptions.onOverrideFieldGroupDetails,
});
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
({ field, groupName, groupIndex, itemIndex, fieldSearchHighlight }) => (
<li key={`field${field.name}`} data-attr-field={field.name}>
<UnifiedFieldListItem
stateService={stateService}
searchMode={searchMode}
services={services}
alwaysShowActionButton={alwaysShowActionButton}
field={field}
highlight={fieldSearchHighlight}
dataView={dataView!}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
trackUiMetric={trackUiMetric}
multiFields={multiFieldsMap?.get(field.name)} // ideally we better calculate multifields when they are requested first from the popover
onEditField={onEditField}
onDeleteField={onDeleteField}
workspaceSelectedFieldNames={workspaceSelectedFieldNames}
groupIndex={groupIndex}
itemIndex={itemIndex}
isEmpty={groupName === FieldsGroupNames.EmptyFields}
isSelected={
groupName === FieldsGroupNames.SelectedFields ||
Boolean(selectedFieldsState.selectedFieldsMap[field.name])
}
/>
</li>
),
[
stateService,
searchMode,
services,
alwaysShowActionButton,
dataView,
onAddFieldToWorkspace,
onRemoveFieldFromWorkspace,
onAddFilter,
trackUiMetric,
multiFieldsMap,
onEditField,
onDeleteField,
workspaceSelectedFieldNames,
selectedFieldsState.selectedFieldsMap,
]
);
if (!dataView) {
return null;
}
return (
<EuiPageSidebar
className="unifiedFieldListSidebar"
aria-label={i18n.translate(
'unifiedFieldList.fieldListSidebar.indexAndFieldsSectionAriaLabel',
{
defaultMessage: 'Index and fields',
}
)}
id={
stateService.creationOptions.dataTestSubj?.fieldListSidebarDataTestSubj ??
'unifiedFieldListSidebarId'
}
data-test-subj={
stateService.creationOptions.dataTestSubj?.fieldListSidebarDataTestSubj ??
'unifiedFieldListSidebarId'
}
>
<EuiFlexGroup
className="unifiedFieldListSidebar__group"
direction="column"
alignItems="stretch"
gutterSize="s"
responsive={false}
>
{Boolean(prepend) && <EuiFlexItem grow={false}>{prepend}</EuiFlexItem>}
<EuiFlexItem>
<FieldList
isProcessing={isProcessing}
prepend={<FieldListFilters {...fieldListFiltersProps} />}
className="unifiedFieldListSidebar__list"
>
{showFieldList ? (
<FieldListGrouped
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
localStorageKeyPrefix={stateService.creationOptions.localStorageKeyPrefix}
/>
) : (
<EuiFlexItem grow />
)}
{!!onEditField && (
<EuiFlexItem grow={false}>
<EuiButton
iconType="indexOpen"
data-test-subj={
stateService.creationOptions.dataTestSubj?.fieldListAddFieldButtonTestSubj ??
'unifiedFieldListAddField'
}
onClick={() => onEditField()}
size="s"
>
{i18n.translate('unifiedFieldList.fieldListSidebar.addFieldButtonLabel', {
defaultMessage: 'Add a field',
})}
</EuiButton>
</EuiFlexItem>
)}
</FieldList>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageSidebar>
);
};
export const UnifiedFieldListSidebar = memo(UnifiedFieldListSidebarComponent);
// Necessary for React.lazy
// eslint-disable-next-line import/no-default-export
export default UnifiedFieldListSidebar;
function calculateMultiFields(
allFields: DataViewField[] | null,
selectedFieldsMap: SelectedFieldsResult['selectedFieldsMap'] | undefined
) {
if (!allFields) {
return undefined;
}
const map = new Map<string, Array<{ field: DataViewField; isSelected: boolean }>>();
allFields.forEach((field) => {
const subTypeMulti = getDataViewFieldSubtypeMulti(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

@ -0,0 +1,349 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
useCallback,
useState,
forwardRef,
useImperativeHandle,
useRef,
useMemo,
useEffect,
} from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import {
EuiBadge,
EuiButton,
EuiFlyout,
EuiFlyoutHeader,
EuiHideFor,
EuiIcon,
EuiLink,
EuiPortal,
EuiShowFor,
EuiTitle,
} from '@elastic/eui';
import {
useExistingFieldsFetcher,
type ExistingFieldsFetcher,
} from '../../hooks/use_existing_fields';
import { useQuerySubscriber } from '../../hooks/use_query_subscriber';
import {
UnifiedFieldListSidebar,
type UnifiedFieldListSidebarCustomizableProps,
type UnifiedFieldListSidebarProps,
} from './field_list_sidebar';
import { createStateService } from '../services/state_service';
import type {
UnifiedFieldListSidebarContainerCreationOptions,
UnifiedFieldListSidebarContainerStateService,
SearchMode,
} from '../../types';
export interface UnifiedFieldListSidebarContainerApi {
refetchFieldsExistenceInfo: ExistingFieldsFetcher['refetchFieldsExistenceInfo'];
closeFieldListFlyout: () => void;
// no user permission or missing dataViewFieldEditor service will result in `undefined` API methods
createField: undefined | (() => void);
editField: undefined | ((fieldName: string) => void);
deleteField: undefined | ((fieldName: string) => void);
}
export type UnifiedFieldListSidebarContainerProps = Omit<
UnifiedFieldListSidebarCustomizableProps,
'services'
> & {
/**
* Required services.
*/
services: UnifiedFieldListSidebarCustomizableProps['services'] & {
dataViewFieldEditor?: IndexPatternFieldEditorStart;
};
/**
* Return static configuration options which don't need to change
*/
getCreationOptions: () => UnifiedFieldListSidebarContainerCreationOptions;
/**
* In case if you have a sidebar toggle button
*/
isSidebarCollapsed?: boolean;
/**
* Custom content to render at the top of field list in the flyout (for example a data view picker)
*/
prependInFlyout?: () => UnifiedFieldListSidebarProps['prepend'];
/**
* Customization for responsive behaviour. Default: `responsive`.
*/
variant?: 'responsive' | 'button-and-flyout-always' | 'list-always';
/**
* Custom logic for determining which field is selected. Otherwise, use `workspaceSelectedFieldNames` prop.
*/
onSelectedFieldFilter?: UnifiedFieldListSidebarProps['onSelectedFieldFilter'];
/**
* Callback to execute after editing/deleting a runtime field
*/
onFieldEdited?: (options?: {
removedFieldName?: string;
editedFieldName?: string;
}) => Promise<void>;
};
/**
* Component providing 2 different renderings for the sidebar depending on available screen space
* Desktop: Sidebar view, all elements are visible
* Mobile: A button to trigger a flyout with all elements
*/
const UnifiedFieldListSidebarContainer = forwardRef<
UnifiedFieldListSidebarContainerApi,
UnifiedFieldListSidebarContainerProps
>(function UnifiedFieldListSidebarContainer(props, componentRef) {
const {
getCreationOptions,
services,
dataView,
workspaceSelectedFieldNames,
isSidebarCollapsed, // TODO later: pull the logic of collapsing the sidebar to this component
prependInFlyout,
variant = 'responsive',
onFieldEdited,
} = props;
const [stateService] = useState<UnifiedFieldListSidebarContainerStateService>(
createStateService({ options: getCreationOptions() })
);
const { data, dataViewFieldEditor } = services;
const [isFieldListFlyoutVisible, setIsFieldListFlyoutVisible] = useState<boolean>(false);
const canEditDataView =
Boolean(dataViewFieldEditor?.userPermissions.editIndexPattern()) ||
Boolean(dataView && !dataView.isPersisted());
const closeFieldEditor = useRef<() => void | undefined>();
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const closeFieldListFlyout = useCallback(() => {
setIsFieldListFlyoutVisible(false);
}, []);
const querySubscriberResult = useQuerySubscriber({
data,
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
});
const searchMode: SearchMode | undefined = querySubscriberResult.searchMode;
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
disableAutoFetching: stateService.creationOptions.disableFieldsExistenceAutoFetching,
dataViews: searchMode === 'documents' && dataView ? [dataView] : [],
query: querySubscriberResult.query,
filters: querySubscriberResult.filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services,
});
const editField = useMemo(
() =>
dataView && dataViewFieldEditor && searchMode === 'documents' && canEditDataView
? (fieldName?: string) => {
const ref = dataViewFieldEditor.openEditor({
ctx: {
dataView,
},
fieldName,
onSave: async () => {
if (onFieldEdited) {
await onFieldEdited({ editedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
: undefined,
[
searchMode,
canEditDataView,
dataViewFieldEditor,
dataView,
setFieldEditorRef,
closeFieldListFlyout,
onFieldEdited,
]
);
const deleteField = useMemo(
() =>
dataView && dataViewFieldEditor && editField
? (fieldName: string) => {
const ref = dataViewFieldEditor.openDeleteModal({
ctx: {
dataView,
},
fieldName,
onDelete: async () => {
if (onFieldEdited) {
await onFieldEdited({ removedFieldName: fieldName });
}
},
});
setFieldEditorRef(ref);
closeFieldListFlyout();
}
: undefined,
[
dataView,
setFieldEditorRef,
editField,
closeFieldListFlyout,
dataViewFieldEditor,
onFieldEdited,
]
);
useEffect(() => {
const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
}
};
return () => {
// Make sure to close the editor when unmounting
cleanup();
};
}, []);
useImperativeHandle(
componentRef,
() => ({
refetchFieldsExistenceInfo,
closeFieldListFlyout,
createField: editField,
editField,
deleteField,
}),
[refetchFieldsExistenceInfo, closeFieldListFlyout, editField, deleteField]
);
if (!dataView) {
return null;
}
const commonSidebarProps: UnifiedFieldListSidebarProps = {
...props,
searchMode,
stateService,
isProcessing,
isAffectedByGlobalFilter,
onEditField: editField,
onDeleteField: deleteField,
};
const buttonPropsToTriggerFlyout = stateService.creationOptions.buttonPropsToTriggerFlyout;
const renderListVariant = () => {
return <UnifiedFieldListSidebar {...commonSidebarProps} />;
};
const renderButtonVariant = () => {
return (
<>
<div className="unifiedFieldListSidebar__mobile">
<EuiButton
{...buttonPropsToTriggerFlyout}
contentProps={{
...buttonPropsToTriggerFlyout?.contentProps,
className: 'unifiedFieldListSidebar__mobileButton',
}}
fullWidth
onClick={() => setIsFieldListFlyoutVisible(true)}
>
<FormattedMessage
id="unifiedFieldList.fieldListSidebar.fieldsMobileButtonLabel"
defaultMessage="Fields"
/>
<EuiBadge
className="unifiedFieldListSidebar__mobileBadge"
color={workspaceSelectedFieldNames?.[0] === '_source' ? 'default' : 'accent'}
>
{!workspaceSelectedFieldNames?.length || workspaceSelectedFieldNames[0] === '_source'
? 0
: workspaceSelectedFieldNames.length}
</EuiBadge>
</EuiButton>
</div>
{isFieldListFlyoutVisible && (
<EuiPortal>
<EuiFlyout
size="s"
onClose={() => setIsFieldListFlyoutVisible(false)}
aria-labelledby="flyoutTitle"
ownFocus
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
<EuiLink color="text" onClick={() => setIsFieldListFlyoutVisible(false)}>
<EuiIcon
className="eui-alignBaseline"
aria-label={i18n.translate(
'unifiedFieldList.fieldListSidebar.flyoutBackIcon',
{
defaultMessage: 'Back',
}
)}
type="arrowLeft"
/>{' '}
<strong>
{i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
defaultMessage: 'Field list',
})}
</strong>
</EuiLink>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<UnifiedFieldListSidebar
{...commonSidebarProps}
alwaysShowActionButton={true}
prepend={prependInFlyout?.()}
/>
</EuiFlyout>
</EuiPortal>
)}
</>
);
};
if (variant === 'button-and-flyout-always') {
return renderButtonVariant();
}
if (variant === 'list-always') {
return (!isSidebarCollapsed && renderListVariant()) || null;
}
return (
<>
{!isSidebarCollapsed && <EuiHideFor sizes={['xs', 's']}>{renderListVariant()}</EuiHideFor>}
<EuiShowFor sizes={['xs', 's']}>{renderButtonVariant()}</EuiShowFor>
</>
);
});
// Necessary for React.lazy
// eslint-disable-next-line import/no-default-export
export default UnifiedFieldListSidebarContainer;

View file

@ -14,9 +14,9 @@ describe('group_fields', function () {
it('should pick fields as unknown_selected if they are unknown', function () {
const actual = getSelectedFields({
dataView,
columns: ['currency'],
workspaceSelectedFieldNames: ['currency'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual).toMatchInlineSnapshot(`
Object {
@ -37,14 +37,14 @@ describe('group_fields', function () {
it('should pick fields as nested for a nested field root', function () {
const actual = getSelectedFields({
dataView,
columns: ['nested1', 'bytes'],
workspaceSelectedFieldNames: ['nested1', 'bytes'],
allFields: [
{
name: 'nested1',
type: 'nested',
},
] as DataViewField[],
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual.selectedFieldsMap).toMatchInlineSnapshot(`
Object {
@ -56,14 +56,19 @@ describe('group_fields', function () {
it('should work correctly if no columns selected', function () {
expect(
getSelectedFields({ dataView, columns: [], allFields: dataView.fields, isPlainRecord: false })
getSelectedFields({
dataView,
workspaceSelectedFieldNames: [],
allFields: dataView.fields,
searchMode: 'documents',
})
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
expect(
getSelectedFields({
dataView,
columns: ['_source'],
workspaceSelectedFieldNames: ['_source'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
})
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
});
@ -71,9 +76,9 @@ describe('group_fields', function () {
it('should pick fields into selected group', function () {
const actual = getSelectedFields({
dataView,
columns: ['bytes', '@timestamp'],
workspaceSelectedFieldNames: ['bytes', '@timestamp'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
expect(actual.selectedFieldsMap).toStrictEqual({
@ -85,9 +90,9 @@ describe('group_fields', function () {
it('should pick fields into selected group if they contain multifields', function () {
const actual = getSelectedFields({
dataView,
columns: ['machine.os', 'machine.os.raw'],
workspaceSelectedFieldNames: ['machine.os', 'machine.os.raw'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual.selectedFields.map((field) => field.name)).toEqual([
'machine.os',
@ -102,9 +107,9 @@ describe('group_fields', function () {
it('should sort selected fields by columns order', function () {
const actual1 = getSelectedFields({
dataView,
columns: ['bytes', 'extension.keyword', 'unknown'],
workspaceSelectedFieldNames: ['bytes', 'extension.keyword', 'unknown'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual1.selectedFields.map((field) => field.name)).toEqual([
'bytes',
@ -119,9 +124,9 @@ describe('group_fields', function () {
const actual2 = getSelectedFields({
dataView,
columns: ['extension', 'bytes', 'unknown'],
workspaceSelectedFieldNames: ['extension', 'bytes', 'unknown'],
allFields: dataView.fields,
isPlainRecord: false,
searchMode: 'documents',
});
expect(actual2.selectedFields.map((field) => field.name)).toEqual([
'extension',
@ -138,14 +143,14 @@ describe('group_fields', function () {
it('should pick fields only from allFields instead of data view fields for a text based query', function () {
const actual = getSelectedFields({
dataView,
columns: ['bytes'],
workspaceSelectedFieldNames: ['bytes'],
allFields: [
{
name: 'bytes',
type: 'text',
},
] as DataViewField[],
isPlainRecord: true,
searchMode: 'text-based',
});
expect(actual).toMatchInlineSnapshot(`
Object {
@ -163,30 +168,38 @@ describe('group_fields', function () {
});
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);
expect(shouldShowField(dataView.getFieldByName('bytes'), 'text-based', false)).toBe(true);
expect(
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'text-based', false)
).toBe(true);
expect(
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'text-based', false)
).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(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
it('should show fields excluding subfields', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', false)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', false)).toBe(
false
);
expect(
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', false)
).toBe(true);
expect(
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', 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(
it('should show fields including subfields', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', true)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', true)).toBe(
true
);
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
false
);
expect(
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', true)
).toBe(true);
expect(
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', true)
).toBe(false);
});
});

View file

@ -12,15 +12,24 @@ import {
type DataView,
getFieldSubtypeMulti,
} from '@kbn/data-views-plugin/public';
import type { SearchMode } from '../../types';
export function shouldShowField(field: DataViewField | undefined, isPlainRecord: boolean): boolean {
export function shouldShowField(
field: DataViewField | undefined,
searchMode: SearchMode | undefined,
disableMultiFieldsGroupingByParent: boolean | undefined
): boolean {
if (!field?.type || field.type === '_source') {
return false;
}
if (isPlainRecord) {
if (searchMode === 'text-based') {
// exclude only `_source` for plain records
return true;
}
if (disableMultiFieldsGroupingByParent) {
// include subfields
return true;
}
// exclude subfields
return !getFieldSubtypeMulti(field?.spec);
}
@ -38,31 +47,36 @@ export interface SelectedFieldsResult {
export function getSelectedFields({
dataView,
columns,
workspaceSelectedFieldNames,
allFields,
isPlainRecord,
searchMode,
}: {
dataView: DataView | undefined;
columns: string[];
workspaceSelectedFieldNames?: string[];
allFields: DataViewField[] | null;
isPlainRecord: boolean;
searchMode: SearchMode | undefined;
}): SelectedFieldsResult {
const result: SelectedFieldsResult = {
selectedFields: [],
selectedFieldsMap: {},
};
if (!Array.isArray(columns) || !columns.length || !allFields) {
if (
!workspaceSelectedFieldNames ||
!Array.isArray(workspaceSelectedFieldNames) ||
!workspaceSelectedFieldNames.length ||
!allFields
) {
return INITIAL_SELECTED_FIELDS_RESULT;
}
// add selected columns, that are not part of the data view, to be removable
for (const column of columns) {
// add selected field names, that are not part of the data view, to be removable
for (const selectedFieldName of workspaceSelectedFieldNames) {
const selectedField =
(!isPlainRecord && dataView?.getFieldByName?.(column)) ||
allFields.find((field) => field.name === column) || // for example to pick a `nested` root field or find a selected field in text-based response
(searchMode === 'documents' && dataView?.getFieldByName?.(selectedFieldName)) ||
allFields.find((field) => field.name === selectedFieldName) || // for example to pick a `nested` root field or find a selected field in text-based response
({
name: column,
displayName: column,
name: selectedFieldName,
displayName: selectedFieldName,
type: 'unknown_selected',
} as DataViewField);
result.selectedFields.push(selectedField);

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
import { EuiDelayRender, EuiLoadingSpinner, EuiPanel } from '@elastic/eui';
import type {
UnifiedFieldListSidebarContainerProps,
UnifiedFieldListSidebarContainerApi,
} from './field_list_sidebar_container';
const LazyUnifiedFieldListSidebarContainer = React.lazy(
() => import('./field_list_sidebar_container')
);
export const UnifiedFieldListSidebarContainer = withSuspense<
UnifiedFieldListSidebarContainerProps,
UnifiedFieldListSidebarContainerApi
>(
LazyUnifiedFieldListSidebarContainer,
<EuiDelayRender delay={300}>
<EuiPanel color="transparent" paddingSize="s">
<EuiLoadingSpinner size="m" />
</EuiPanel>
</EuiDelayRender>
);
export type { UnifiedFieldListSidebarContainerProps, UnifiedFieldListSidebarContainerApi };

View file

@ -14,9 +14,9 @@ import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common
import { type DataViewsContract } from '@kbn/data-views-plugin/public';
import {
type FieldListGroups,
type FieldsGroupDetails,
type FieldsGroup,
type FieldListItem,
type OverrideFieldGroupDetails,
FieldsGroupNames,
ExistenceFetchStatus,
} from '../types';
@ -38,9 +38,7 @@ export interface GroupedFieldsParams<T extends FieldListItem> {
popularFieldsLimit?: number;
sortedSelectedFields?: T[];
getCustomFieldType?: FieldFiltersParams<T>['getCustomFieldType'];
onOverrideFieldGroupDetails?: (
groupName: FieldsGroupNames
) => Partial<FieldsGroupDetails> | undefined | null;
onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
onSupportedFieldFilter?: (field: T) => boolean;
onSelectedFieldFilter?: (field: T) => boolean;
}

View file

@ -9,14 +9,19 @@
import { useEffect, useState } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query';
import { getResolvedDateRange } from '../utils/get_resolved_date_range';
import type { TimeRangeUpdatesType, SearchMode } from '../types';
/**
* Hook params
*/
export interface QuerySubscriberParams {
data: DataPublicPluginStart;
listenToSearchSessionUpdates?: boolean;
/**
* Pass `timefilter` only if you are not using search sessions for the global search
*/
timeRangeUpdatesType?: TimeRangeUpdatesType;
}
/**
@ -27,17 +32,18 @@ export interface QuerySubscriberResult {
filters: Filter[] | undefined;
fromDate: string | undefined;
toDate: string | undefined;
searchMode: SearchMode | undefined;
}
/**
* Memorizes current query, filters and absolute date range
* @param data
* @param listenToSearchSessionUpdates
* @param timeRangeUpdatesType
* @public
*/
export const useQuerySubscriber = ({
data,
listenToSearchSessionUpdates = true,
timeRangeUpdatesType = 'search-session',
}: QuerySubscriberParams) => {
const timefilter = data.query.timefilter.timefilter;
const [result, setResult] = useState<QuerySubscriberResult>(() => {
@ -48,11 +54,12 @@ export const useQuerySubscriber = ({
filters: state?.filters,
fromDate: dateRange.fromDate,
toDate: dateRange.toDate,
searchMode: getSearchMode(state?.query),
};
});
useEffect(() => {
if (!listenToSearchSessionUpdates) {
if (timeRangeUpdatesType !== 'search-session') {
return;
}
@ -66,10 +73,10 @@ export const useQuerySubscriber = ({
});
return () => subscription.unsubscribe();
}, [setResult, timefilter, data.search.session.state$, listenToSearchSessionUpdates]);
}, [setResult, timefilter, data.search.session.state$, timeRangeUpdatesType]);
useEffect(() => {
if (listenToSearchSessionUpdates) {
if (timeRangeUpdatesType !== 'timefilter') {
return;
}
@ -83,7 +90,7 @@ export const useQuerySubscriber = ({
});
return () => subscription.unsubscribe();
}, [setResult, timefilter, listenToSearchSessionUpdates]);
}, [setResult, timefilter, timeRangeUpdatesType]);
useEffect(() => {
const subscription = data.query.state$.subscribe(({ state, changes }) => {
@ -92,6 +99,7 @@ export const useQuerySubscriber = ({
...prevState,
query: state.query,
filters: state.filters,
searchMode: getSearchMode(state.query),
}));
}
});
@ -114,4 +122,25 @@ export const hasQuerySubscriberData = (
filters: Filter[];
fromDate: string;
toDate: string;
} => Boolean(result.query && result.filters && result.fromDate && result.toDate);
searchMode: SearchMode;
} =>
Boolean(result.query && result.filters && result.fromDate && result.toDate && result.searchMode);
/**
* Determines current search mode
* @param query
*/
export function getSearchMode(query?: Query | AggregateQuery): SearchMode | undefined {
if (!query) {
return undefined;
}
if (
isOfAggregateQueryType(query) &&
(getAggregateQueryMode(query) === 'sql' || getAggregateQueryMode(query) === 'esql')
) {
return 'text-based';
}
return 'documents';
}

View file

@ -7,6 +7,7 @@
*/
import type { DataViewField } from '@kbn/data-views-plugin/common';
import type { EuiButtonIconProps, EuiButtonProps } from '@elastic/eui';
export interface BucketedAggregation<KeyType = string> {
buckets: Array<{
@ -103,3 +104,93 @@ export interface RenderFieldItemParams<T extends FieldListItem> {
groupName: FieldsGroupNames;
fieldSearchHighlight?: string;
}
export type OverrideFieldGroupDetails = (
groupName: FieldsGroupNames
) => Partial<FieldsGroupDetails> | undefined | null;
export type TimeRangeUpdatesType = 'search-session' | 'timefilter';
export type SearchMode = 'documents' | 'text-based';
export interface UnifiedFieldListSidebarContainerCreationOptions {
/**
* Plugin ID
*/
originatingApp: string;
/**
* Your app name: "discover", "lens", etc. If not provided, sections state would not be persisted.
*/
localStorageKeyPrefix?: string;
/**
* Pass `timefilter` only if you are not using search sessions for the global search
*/
timeRangeUpdatesType?: TimeRangeUpdatesType;
/**
* Pass `true` to skip auto fetching of fields existence info
*/
disableFieldsExistenceAutoFetching?: boolean;
/**
* Pass `true` to see all multi fields flattened in the list. Otherwise, they will show in a field popover.
*/
disableMultiFieldsGroupingByParent?: boolean;
/**
* Pass `true` to not have "Popular Fields" section in the field list
*/
disablePopularFields?: boolean;
/**
* Pass `true` to have non-draggable field list items (like in the mobile flyout)
*/
disableFieldListItemDragAndDrop?: boolean;
/**
* This button will be shown in mobile view
*/
buttonPropsToTriggerFlyout?: Partial<EuiButtonProps>;
/**
* Custom props like `aria-label`
*/
buttonAddFieldToWorkspaceProps?: Partial<EuiButtonIconProps>;
/**
* Custom props like `aria-label`
*/
buttonRemoveFieldFromWorkspaceProps?: Partial<EuiButtonIconProps>;
/**
* Return custom configuration for field list sections
*/
onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
/**
* Use this predicate to hide certain fields
* @param field
*/
onSupportedFieldFilter?: (field: DataViewField) => boolean;
/**
* Custom `data-test-subj`. Mostly for preserving legacy values.
*/
dataTestSubj?: {
fieldListAddFieldButtonTestSubj?: string;
fieldListSidebarDataTestSubj?: string;
fieldListItemStatsDataTestSubj?: string;
fieldListItemDndDataTestSubjPrefix?: string;
fieldListItemPopoverDataTestSubj?: string;
fieldListItemPopoverHeaderDataTestSubjPrefix?: string;
};
}
/**
* The service used to manage the state of the container
*/
export interface UnifiedFieldListSidebarContainerStateService {
creationOptions: UnifiedFieldListSidebarContainerCreationOptions;
}

View file

@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "target/types"
},
"include": ["*.ts", "src/**/*"],
"include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"],
"kbn_references": [
"@kbn/i18n",
"@kbn/data-views-plugin",
@ -24,6 +24,9 @@
"@kbn/field-types",
"@kbn/ui-actions-browser",
"@kbn/data-service",
"@kbn/data-view-field-editor-plugin",
"@kbn/dom-drag-drop",
"@kbn/shared-ux-utility",
],
"exclude": ["target/**/*"]
}

View file

@ -92,15 +92,55 @@ export function createDiscoverServicesMock(): DiscoverServices {
return searchSource;
});
const corePluginMock = coreMock.createStart();
const uiSettingsMock: Partial<typeof corePluginMock.uiSettings> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get: jest.fn((key: string): any => {
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;
} else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
return true;
}
}),
isDefault: jest.fn((key: string) => {
return true;
}),
};
corePluginMock.uiSettings = {
...corePluginMock.uiSettings,
...uiSettingsMock,
};
const theme = {
theme$: of({ darkMode: false }),
};
corePluginMock.theme = theme;
return {
core: {
...coreMock.createStart(),
theme,
},
core: corePluginMock,
charts: chartPluginMock.createSetupContract(),
chrome: chromeServiceMock.createStartContract(),
history: () => ({
@ -128,50 +168,20 @@ export function createDiscoverServicesMock(): DiscoverServices {
open: jest.fn(),
},
uiActions: uiActionsPluginMock.createStartContract(),
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;
} else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
return true;
}
}),
isDefault: (key: string) => {
return true;
},
},
uiSettings: uiSettingsMock,
http: {
basePath: '/',
},
dataViewEditor: {
openEditor: jest.fn(),
userPermissions: {
editDataView: () => true,
editDataView: jest.fn(() => true),
},
},
dataViewFieldEditor: {
openEditor: jest.fn(),
userPermissions: {
editIndexPattern: jest.fn(),
editIndexPattern: jest.fn(() => true),
},
},
navigation: {

View file

@ -8,6 +8,7 @@
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
import { EuiPageSidebar } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { Query, AggregateQuery } from '@kbn/es-query';
import { setHeaderActionMenuMounter } from '../../../../kibana_services';
@ -31,7 +32,6 @@ import {
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
import { DiscoverSidebar } from '../sidebar/discover_sidebar';
import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { DiscoverServices } from '../../../../build_services';
@ -164,17 +164,17 @@ describe('Discover component', () => {
describe('sidebar', () => {
test('should be opened if discover:sidebarClosed was not set', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, undefined);
expect(component.find(DiscoverSidebar).length).toBe(1);
expect(component.find(EuiPageSidebar).length).toBe(1);
}, 10000);
test('should be opened if discover:sidebarClosed is false', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, false);
expect(component.find(DiscoverSidebar).length).toBe(1);
expect(component.find(EuiPageSidebar).length).toBe(1);
}, 10000);
test('should be closed if discover:sidebarClosed is true', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, true);
expect(component.find(DiscoverSidebar).length).toBe(0);
expect(component.find(EuiPageSidebar).length).toBe(0);
}, 10000);
});

View file

@ -289,9 +289,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
selectedDataView={dataView}
isClosed={isSidebarClosed}
trackUiMetric={trackUiMetric}
useNewFieldsApi={useNewFieldsApi}
onFieldEdited={onFieldEdited}
viewMode={viewMode}
onDataViewCreated={stateContainer.actions.onDataViewCreated}
availableFields$={stateContainer.dataState.data$.availableFields$}
/>

View file

@ -1,5 +0,0 @@
.dscSidebarItem--multi {
.kbnFieldButton__button {
padding-left: 0;
}
}

View file

@ -1,299 +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 { 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 {
DiscoverSidebarComponent as DiscoverSidebar,
DiscoverSidebarProps,
} from './discover_sidebar';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { BehaviorSubject } from 'rxjs';
import { FetchStatus } from '../../../types';
import { AvailableFields$, DataDocuments$ } from '../../services/discover_data_state_container';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { VIEW_MODE } from '../../../../../common/constants';
import { DiscoverMainProvider } from '../../services/discover_state_provider';
import * as ExistingFieldsHookApi from '@kbn/unified-field-list/src/hooks/use_existing_fields';
import { ExistenceFetchStatus } from '@kbn/unified-field-list/src/types';
import { getDataViewFieldList } from './lib/get_field_list';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
import type { SearchBarCustomization } from '../../../../customizations';
const mockGetActions = jest.fn<Promise<Array<Action<object>>>, [string, { fieldName: string }]>(
() => Promise.resolve([])
);
jest.spyOn(ExistingFieldsHookApi, 'useExistingFieldsReader');
jest.mock('../../../../kibana_services', () => ({
getUiActions: () => ({
getTriggerCompatibleActions: mockGetActions,
}),
}));
const mockSearchBarCustomization: SearchBarCustomization = {
id: 'search_bar',
CustomDataViewPicker: jest.fn(() => <div data-test-subj="custom-data-view-picker" />),
};
let mockUseCustomizations = false;
jest.mock('../../../../customizations', () => ({
...jest.requireActual('../../../../customizations'),
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
if (!mockUseCustomizations) {
return undefined;
}
switch (id) {
case 'search_bar':
return mockSearchBarCustomization;
default:
throw new Error(`Unknown customization id: ${id}`);
}
}),
}));
function getStateContainer({ query }: { query?: Query | AggregateQuery }) {
const state = getDiscoverStateMock({ isTimeBased: true });
state.appState.set({
query: query ?? { query: '', language: 'lucene' },
filters: [],
});
state.internalState.transitions.setDataView(stubLogstashDataView);
return state;
}
function getCompProps(): DiscoverSidebarProps {
const dataView = stubLogstashDataView;
dataView.toSpec = jest.fn(() => ({}));
const hits = getDataTableRecords(dataView);
const fieldCounts: Record<string, number> = {};
for (const hit of hits) {
for (const key of Object.keys(hit.flattened)) {
fieldCounts[key] = (fieldCounts[key] || 0) + 1;
}
}
const allFields = getDataViewFieldList(dataView, fieldCounts);
(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'],
allFields,
onChangeDataView: jest.fn(),
onAddFilter: jest.fn(),
onAddField: jest.fn(),
onRemoveField: jest.fn(),
selectedDataView: dataView,
trackUiMetric: jest.fn(),
onFieldEdited: jest.fn(),
editField: jest.fn(),
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
createNewDataView: jest.fn(),
onDataViewCreated: jest.fn(),
documents$,
availableFields$,
useNewFieldsApi: true,
showFieldList: true,
isAffectedByGlobalFilter: false,
isProcessing: false,
};
}
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.selectedDataView
? [{ id: props.selectedDataView.id!, title: props.selectedDataView.title! }]
: []
);
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}>
<DiscoverMainProvider value={getStateContainer(appStateParams)}>
<DiscoverSidebar {...props} />
</DiscoverMainProvider>
</KibanaContextProvider>
);
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update();
});
await comp!.update();
return comp!;
}
describe('discover sidebar', function () {
let props: DiscoverSidebarProps;
beforeEach(async () => {
props = getCompProps();
mockUseCustomizations = false;
});
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', 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', async () => {
const comp = await mountComponent(props);
const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
expect(addFieldButton.length).toBe(1);
addFieldButton.simulate('click');
expect(props.editField).toHaveBeenCalledWith();
});
it('should render "Edit field" button', async () => {
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);
editFieldButton.simulate('click');
expect(props.editField).toHaveBeenCalledWith('bytes');
});
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);
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 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);
// click "Add a field"
const addFieldButtonInDataViewPicker = findTestSubject(
compWithPicker,
'indexPattern-add-field'
);
expect(addFieldButtonInDataViewPicker.length).toBe(1);
addFieldButtonInDataViewPicker.simulate('click');
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(propsWithPicker.createNewDataView).toHaveBeenCalled();
});
it('should not render buttons in data view picker when in viewer mode', async () => {
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);
// check that buttons are not present
const addFieldButtonInDataViewPicker = findTestSubject(
compWithPickerInViewerMode,
'dataView-add-field'
);
expect(addFieldButtonInDataViewPicker.length).toBe(0);
const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
expect(createDataViewButton.length).toBe(0);
});
describe('search bar customization', () => {
it('should render CustomDataViewPicker', async () => {
mockUseCustomizations = true;
const comp = await mountComponent({ ...props, showDataViewPicker: true });
expect(comp.find('[data-test-subj="custom-data-view-picker"]').length).toBe(1);
});
});
});

View file

@ -1,361 +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 './discover_sidebar.scss';
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPageSidebar } from '@elastic/eui';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { type DataViewField, getFieldSubtypeMulti } from '@kbn/data-views-plugin/public';
import {
FieldList,
FieldListFilters,
FieldListGrouped,
FieldListGroupedProps,
FieldsGroupNames,
GroupedFieldsParams,
useGroupedFields,
} from '@kbn/unified-field-list';
import { VIEW_MODE } from '../../../../../common/constants';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverField } from './discover_field';
import { FIELDS_LIMIT_SETTING } from '../../../../../common';
import {
getSelectedFields,
shouldShowField,
type SelectedFieldsResult,
INITIAL_SELECTED_FIELDS_RESULT,
} from './lib/group_fields';
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { getRawRecordType } from '../../utils/get_raw_record_type';
import { RecordRawType } from '../../services/discover_data_state_container';
import { useDiscoverCustomization } from '../../../../customizations';
export interface DiscoverSidebarProps extends DiscoverSidebarResponsiveProps {
/**
* Show loading instead of the field list if processing
*/
isProcessing: boolean;
/**
* Callback to close the flyout if sidebar is rendered in a flyout
*/
closeFlyout?: () => void;
/**
* Pass the reference to field editor component to the parent, so it can be properly unmounted
* @param ref reference to the field editor component
*/
setFieldEditorRef?: (ref: () => void | undefined) => void;
/**
* Handles "Edit field" action
* Buttons will be hidden if not provided
* @param fieldName
*/
editField?: (fieldName?: string) => void;
/**
* Handles "Create a data view action" action
* Buttons will be hidden if not provided
*/
createNewDataView?: () => void;
/**
* All fields: fields from data view and unmapped fields or columns from text-based search
*/
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({
isProcessing,
alwaysShowActionButtons = false,
columns,
allFields,
onAddField,
onAddFilter,
onRemoveField,
selectedDataView,
trackUiMetric,
useNewFieldsApi = false,
onFieldEdited,
onChangeDataView,
setFieldEditorRef,
closeFlyout,
editField,
viewMode,
createNewDataView,
showDataViewPicker,
showFieldList,
isAffectedByGlobalFilter,
}: DiscoverSidebarProps) {
const { uiSettings, dataViewFieldEditor, dataViews, core } = useDiscoverServices();
const isPlainRecord = useAppStateSelector(
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
);
const [selectedFieldsState, setSelectedFieldsState] = useState<SelectedFieldsResult>(
INITIAL_SELECTED_FIELDS_RESULT
);
const [multiFieldsMap, setMultiFieldsMap] = useState<
Map<string, Array<{ field: DataViewField; isSelected: boolean }>> | undefined
>(undefined);
useEffect(() => {
const result = getSelectedFields({
dataView: selectedDataView,
columns,
allFields,
isPlainRecord,
});
setSelectedFieldsState(result);
}, [selectedDataView, columns, setSelectedFieldsState, allFields, isPlainRecord]);
useEffect(() => {
if (isPlainRecord || !useNewFieldsApi) {
setMultiFieldsMap(undefined); // we don't have to calculate multifields in this case
} else {
setMultiFieldsMap(
calculateMultiFields(allFields, selectedFieldsState.selectedFieldsMap, useNewFieldsApi)
);
}
}, [
selectedFieldsState.selectedFieldsMap,
allFields,
useNewFieldsApi,
setMultiFieldsMap,
isPlainRecord,
]);
const deleteField = useMemo(
() =>
editField && selectedDataView
? async (fieldName: string) => {
const ref = dataViewFieldEditor.openDeleteModal({
ctx: {
dataView: selectedDataView,
},
fieldName,
onDelete: async () => {
await onFieldEdited({ removedFieldName: fieldName });
},
});
if (setFieldEditorRef) {
setFieldEditorRef(ref);
}
if (closeFlyout) {
closeFlyout();
}
}
: undefined,
[
selectedDataView,
editField,
setFieldEditorRef,
closeFlyout,
onFieldEdited,
dataViewFieldEditor,
]
);
const popularFieldsLimit = useMemo(() => uiSettings.get(FIELDS_LIMIT_SETTING), [uiSettings]);
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 { fieldListFiltersProps, fieldListGroupedProps } = useGroupedFields({
dataViewId: (!isPlainRecord && selectedDataView?.id) || null, // passing `null` for text-based queries
allFields,
popularFieldsLimit: !isPlainRecord ? popularFieldsLimit : 0,
sortedSelectedFields: selectedFieldsState.selectedFields,
isAffectedByGlobalFilter,
services: {
dataViews,
core,
},
onSupportedFieldFilter,
onOverrideFieldGroupDetails,
});
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
({ field, groupName, groupIndex, itemIndex, fieldSearchHighlight }) => (
<li key={`field${field.name}`} data-attr-field={field.name}>
<DiscoverField
alwaysShowActionButton={alwaysShowActionButtons}
field={field}
highlight={fieldSearchHighlight}
dataView={selectedDataView!}
onAddField={onAddField}
onRemoveField={onRemoveField}
onAddFilter={onAddFilter}
trackUiMetric={trackUiMetric}
multiFields={multiFieldsMap?.get(field.name)} // ideally we better calculate multifields when they are requested first from the popover
onEditField={editField}
onDeleteField={deleteField}
contextualFields={columns}
groupIndex={groupIndex}
itemIndex={itemIndex}
isEmpty={groupName === FieldsGroupNames.EmptyFields}
isSelected={
groupName === FieldsGroupNames.SelectedFields ||
Boolean(selectedFieldsState.selectedFieldsMap[field.name])
}
/>
</li>
),
[
alwaysShowActionButtons,
selectedDataView,
onAddField,
onRemoveField,
onAddFilter,
trackUiMetric,
multiFieldsMap,
editField,
deleteField,
columns,
selectedFieldsState.selectedFieldsMap,
]
);
const searchBarCustomization = useDiscoverCustomization('search_bar');
if (!selectedDataView) {
return null;
}
return (
<EuiPageSidebar
className="dscSidebar"
aria-label={i18n.translate('discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel', {
defaultMessage: 'Index and fields',
})}
id="discover-sidebar"
data-test-subj="discover-sidebar"
>
<EuiFlexGroup
className="dscSidebar__group"
direction="column"
alignItems="stretch"
gutterSize="s"
responsive={false}
>
{Boolean(showDataViewPicker) &&
(searchBarCustomization?.CustomDataViewPicker ? (
<searchBarCustomization.CustomDataViewPicker />
) : (
<DataViewPicker
currentDataViewId={selectedDataView.id}
onChangeDataView={onChangeDataView}
onAddField={editField}
onDataViewCreated={createNewDataView}
trigger={{
label: selectedDataView?.getName() || '',
'data-test-subj': 'dataView-switch-link',
title: selectedDataView?.getIndexPattern() || '',
fullWidth: true,
}}
/>
))}
<EuiFlexItem>
<FieldList
isProcessing={isProcessing}
prepend={<FieldListFilters {...fieldListFiltersProps} />}
className="dscSidebar__list"
>
{showFieldList ? (
<FieldListGrouped
{...fieldListGroupedProps}
renderFieldItem={renderFieldItem}
localStorageKeyPrefix="discover"
/>
) : (
<EuiFlexItem grow />
)}
{!!editField && (
<EuiFlexItem grow={false}>
<EuiButton
iconType="indexOpen"
data-test-subj="dataView-add-field_btn"
onClick={() => editField()}
size="s"
>
{i18n.translate('discover.fieldChooser.addField.label', {
defaultMessage: 'Add a field',
})}
</EuiButton>
</EuiFlexItem>
)}
</FieldList>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageSidebar>
);
}
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

@ -29,13 +29,39 @@ import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
import { VIEW_MODE } from '../../../../../common/constants';
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list/src/services/field_existing/load_field_existing';
import { resetExistingFieldsCache } from '@kbn/unified-field-list/src/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';
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
import type { SearchBarCustomization } from '../../../../customizations';
const mockSearchBarCustomization: SearchBarCustomization = {
id: 'search_bar',
CustomDataViewPicker: jest
.fn(() => <div data-test-subj="custom-data-view-picker" />)
.mockName('CustomDataViewPickerMock'),
};
let mockUseCustomizations = false;
jest.mock('../../../../customizations', () => ({
...jest.requireActual('../../../../customizations'),
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
if (!mockUseCustomizations) {
return undefined;
}
switch (id) {
case 'search_bar':
return mockSearchBarCustomization;
default:
throw new Error(`Unknown customization id: ${id}`);
}
}),
}));
jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({
@ -89,11 +115,6 @@ function createMockServices() {
}),
},
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
dataViewEditor: {
userPermissions: {
editDataView: jest.fn(() => true),
},
},
} as unknown as DiscoverServices;
return mockServices;
}
@ -146,9 +167,7 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
selectedDataView: dataView,
trackUiMetric: jest.fn(),
onFieldEdited: jest.fn(),
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
onDataViewCreated: jest.fn(),
useNewFieldsApi: true,
};
}
@ -167,6 +186,7 @@ async function mountComponent(
services?: DiscoverServices
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
const appState = getAppStateContainer(appStateParams);
const mockedServices = services ?? createMockServices();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () =>
props.selectedDataView
@ -176,11 +196,12 @@ async function mountComponent(
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
return [props.selectedDataView].find((d) => d!.id === id);
});
mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState());
await act(async () => {
comp = await mountWithIntl(
<KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
<DiscoverAppStateProvider value={appState}>
<DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider>
</KibanaContextProvider>
@ -204,6 +225,7 @@ describe('discover responsive sidebar', function () {
existingFieldNames: Object.keys(mockfieldCounts),
}));
props = getCompProps();
mockUseCustomizations = false;
});
afterEach(() => {
@ -221,7 +243,16 @@ describe('discover responsive sidebar', function () {
});
});
const compLoadingExistence = await mountComponent(props);
const compLoadingExistence = await mountComponent({
...props,
fieldListVariant: 'list-always',
});
await act(async () => {
// wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0));
await compLoadingExistence.update();
});
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
@ -477,32 +508,44 @@ describe('discover responsive sidebar', function () {
result: getDataTableRecords(stubLogstashDataView),
textBasedQueryColumns: [
{ id: '1', name: 'extension', meta: { type: 'text' } },
{ id: '1', name: 'bytes', meta: { type: 'number' } },
{ id: '1', name: '@timestamp', meta: { type: 'date' } },
{ id: '2', name: 'bytes', meta: { type: 'number' } },
{ id: '3', name: '@timestamp', meta: { type: 'date' } },
],
}) as DataDocuments$,
};
const compInViewerMode = await mountComponent(propsWithTextBasedMode, {
const compInTextBasedMode = await mountComponent(propsWithTextBasedMode, {
query: { sql: 'SELECT * FROM `index`' },
});
expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await compInTextBasedMode.update();
});
expect(findTestSubject(compInTextBasedMode, 'indexPattern-add-field_btn').length).toBe(0);
const popularFieldsCount = findTestSubject(
compInViewerMode,
compInTextBasedMode,
'fieldListGroupedPopularFields-count'
);
const selectedFieldsCount = findTestSubject(
compInViewerMode,
compInTextBasedMode,
'fieldListGroupedSelectedFields-count'
);
const availableFieldsCount = findTestSubject(
compInViewerMode,
compInTextBasedMode,
'fieldListGroupedAvailableFields-count'
);
const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count');
const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count');
const emptyFieldsCount = findTestSubject(
compInTextBasedMode,
'fieldListGroupedEmptyFields-count'
);
const metaFieldsCount = findTestSubject(
compInTextBasedMode,
'fieldListGroupedMetaFields-count'
);
const unmappedFieldsCount = findTestSubject(
compInViewerMode,
compInTextBasedMode,
'fieldListGroupedUnmappedFields-count'
);
@ -515,7 +558,7 @@ describe('discover responsive sidebar', function () {
expect(mockCalcFieldCounts.mock.calls.length).toBe(0);
expect(findTestSubject(compInViewerMode, 'fieldListGrouped__ariaDescription').text()).toBe(
expect(findTestSubject(compInTextBasedMode, 'fieldListGrouped__ariaDescription').text()).toBe(
'2 selected fields. 3 available fields.'
);
});
@ -548,9 +591,162 @@ describe('discover responsive sidebar', function () {
it('should not show "Add a field" button in viewer mode', async () => {
const services = createMockServices();
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
services.dataViewFieldEditor.userPermissions.editIndexPattern = 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);
});
it('should hide field list if documents status is not initialized', async function () {
const comp = await mountComponent({
...props,
documents$: new BehaviorSubject({
fetchStatus: FetchStatus.UNINITIALIZED,
}) as DataDocuments$,
});
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(false);
});
it('should render "Add a field" button', async () => {
const services = createMockServices();
const comp = await mountComponent(
{
...props,
fieldListVariant: 'list-always',
},
{},
services
);
const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
expect(addFieldButton.length).toBe(1);
await addFieldButton.simulate('click');
expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
});
it('should render "Edit field" button', async () => {
const services = createMockServices();
const comp = await mountComponent(props, {}, services);
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);
await editFieldButton.simulate('click');
expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
});
it('should not render Add/Edit field buttons in viewer mode', async () => {
const services = createMockServices();
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
const compInViewerMode = await mountComponent(props, {}, services);
const addFieldButton = findTestSubject(compInViewerMode, 'dataView-add-field_btn');
expect(addFieldButton.length).toBe(0);
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);
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
});
it('should render buttons in data view picker correctly', async () => {
const services = createMockServices();
const propsWithPicker: DiscoverSidebarResponsiveProps = {
...props,
fieldListVariant: 'button-and-flyout-always',
};
const compWithPicker = await mountComponent(propsWithPicker, {}, services);
// open flyout
await act(async () => {
compWithPicker.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
await compWithPicker.update();
});
await compWithPicker.update();
// open data view picker
await findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click');
expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1);
// check "Add a field"
const addFieldButtonInDataViewPicker = findTestSubject(
compWithPicker,
'indexPattern-add-field'
);
expect(addFieldButtonInDataViewPicker.length).toBe(1);
// click "Create a data view"
const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new');
expect(createDataViewButton.length).toBe(1);
await createDataViewButton.simulate('click');
expect(services.dataViewEditor.openEditor).toHaveBeenCalled();
});
it('should not render buttons in data view picker when in viewer mode', async () => {
const services = createMockServices();
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
const propsWithPicker: DiscoverSidebarResponsiveProps = {
...props,
fieldListVariant: 'button-and-flyout-always',
};
const compWithPickerInViewerMode = await mountComponent(propsWithPicker, {}, services);
// open flyout
await act(async () => {
compWithPickerInViewerMode
.find('.unifiedFieldListSidebar__mobileButton')
.last()
.simulate('click');
await compWithPickerInViewerMode.update();
});
await compWithPickerInViewerMode.update();
// open data view picker
findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click');
expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1);
// check that buttons are not present
const addFieldButtonInDataViewPicker = findTestSubject(
compWithPickerInViewerMode,
'dataView-add-field'
);
expect(addFieldButtonInDataViewPicker.length).toBe(0);
const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
expect(createDataViewButton.length).toBe(0);
});
describe('search bar customization', () => {
it('should not render CustomDataViewPicker', async () => {
mockUseCustomizations = false;
const comp = await mountComponent({
...props,
fieldListVariant: 'button-and-flyout-always',
});
await act(async () => {
comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
await comp.update();
});
await comp.update();
expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(false);
});
it('should render CustomDataViewPicker', async () => {
mockUseCustomizations = true;
const comp = await mountComponent({
...props,
fieldListVariant: 'button-and-flyout-always',
});
await act(async () => {
comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
await comp.update();
});
await comp.update();
expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(true);
});
});
});

View file

@ -7,26 +7,18 @@
*/
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';
import {
EuiBadge,
EuiButton,
EuiFlyout,
EuiFlyoutHeader,
EuiHideFor,
EuiIcon,
EuiLink,
EuiPortal,
EuiShowFor,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { useExistingFieldsFetcher, useQuerySubscriber } from '@kbn/unified-field-list';
import { VIEW_MODE } from '../../../../../common/constants';
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import {
UnifiedFieldListSidebarContainer,
type UnifiedFieldListSidebarContainerProps,
type UnifiedFieldListSidebarContainerApi,
FieldsGroupNames,
} from '@kbn/unified-field-list';
import { PLUGIN_ID } from '../../../../../common';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverSidebar } from './discover_sidebar';
import {
AvailableFields$,
DataDocuments$,
@ -35,22 +27,58 @@ import {
import { calcFieldCounts } from '../../utils/calc_field_counts';
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 { getUiActions } from '../../../../kibana_services';
import {
discoverSidebarReducer,
getInitialState,
DiscoverSidebarReducerActionType,
DiscoverSidebarReducerStatus,
} from './lib/sidebar_reducer';
import { useDiscoverCustomization } from '../../../../customizations';
const EMPTY_FIELD_COUNTS = {};
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
return {
originatingApp: PLUGIN_ID,
localStorageKeyPrefix: 'discover',
disableFieldsExistenceAutoFetching: true,
buttonPropsToTriggerFlyout: {
contentProps: {
id: DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields,
},
},
buttonAddFieldToWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
defaultMessage: 'Add field as column',
}),
},
buttonRemoveFieldFromWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
}),
},
onOverrideFieldGroupDetails: (groupName) => {
if (groupName === FieldsGroupNames.AvailableFields) {
return {
helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', {
defaultMessage: 'Fields available for display in the table.',
}),
};
}
},
dataTestSubj: {
fieldListAddFieldButtonTestSubj: 'dataView-add-field_btn',
fieldListSidebarDataTestSubj: 'discover-sidebar',
fieldListItemStatsDataTestSubj: 'dscFieldStats',
fieldListItemDndDataTestSubjPrefix: 'dscFieldListPanelField',
fieldListItemPopoverDataTestSubj: 'discoverFieldListPanelPopover',
fieldListItemPopoverHeaderDataTestSubjPrefix: 'discoverFieldListPanel',
},
};
};
export interface DiscoverSidebarResponsiveProps {
/**
* Determines whether add/remove buttons are displayed non only when focused
*/
alwaysShowActionButtons?: boolean;
/**
* the selected columns displayed in the doc table in discover
*/
@ -90,10 +118,6 @@ export interface DiscoverSidebarResponsiveProps {
* @param eventName
*/
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
/**
* Read from the Fields API
*/
useNewFieldsApi: boolean;
/**
* callback to execute on edit runtime field
*/
@ -102,14 +126,14 @@ export interface DiscoverSidebarResponsiveProps {
* callback to execute on create dataview
*/
onDataViewCreated: (dataView: DataView) => void;
/**
* Discover view mode
*/
viewMode: VIEW_MODE;
/**
* list of available fields fetched from ES
*/
availableFields$: AvailableFields$;
/**
* For customization and testing purposes
*/
fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant'];
}
/**
@ -119,12 +143,18 @@ 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 [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
const {
fieldListVariant,
selectedDataView,
columns,
trackUiMetric,
onAddFilter,
onFieldEdited,
onDataViewCreated,
onChangeDataView,
onAddField,
onRemoveField,
} = props;
const [sidebarState, dispatchSidebarStateAction] = useReducer(
discoverSidebarReducer,
selectedDataView,
@ -132,6 +162,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
);
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
useState<UnifiedFieldListSidebarContainerApi | null>(null);
useEffect(() => {
const subscription = props.documents$.subscribe((documentState) => {
@ -196,38 +228,50 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
}
}, [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,
},
});
const refetchFieldsExistenceInfo =
unifiedFieldListSidebarContainerApi?.refetchFieldsExistenceInfo;
const scheduleFieldsExistenceInfoFetchRef = useRef<boolean>(false);
// Refetch fields existence info only after the fetch completes
useEffect(() => {
if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) {
refetchFieldsExistenceInfo();
}
// refetching only if status changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sidebarState.status]);
scheduleFieldsExistenceInfoFetchRef.current = false;
if (sidebarState.status !== DiscoverSidebarReducerStatus.COMPLETED) {
return;
}
// refetching info only if status changed to completed
if (refetchFieldsExistenceInfo) {
refetchFieldsExistenceInfo();
} else {
scheduleFieldsExistenceInfoFetchRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sidebarState.status, scheduleFieldsExistenceInfoFetchRef]);
// As unifiedFieldListSidebarContainerRef ref can be empty in the beginning,
// we need to fetch the data once API becomes available and after documents are fetched
const initializeUnifiedFieldListSidebarContainerApi = useCallback(
(api) => {
if (!api) {
return;
}
if (scheduleFieldsExistenceInfoFetchRef.current) {
scheduleFieldsExistenceInfoFetchRef.current = false;
api.refetchFieldsExistenceInfo();
}
setUnifiedFieldListSidebarContainerApi(api);
},
[setUnifiedFieldListSidebarContainerApi, scheduleFieldsExistenceInfoFetchRef]
);
const closeFieldEditor = useRef<() => void | undefined>();
const closeDataViewEditor = useRef<() => void | undefined>();
useEffect(() => {
const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
}
if (closeDataViewEditor?.current) {
closeDataViewEditor?.current();
}
@ -238,24 +282,13 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
};
}, []);
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
closeFieldEditor.current = ref;
}, []);
const setDataViewEditorRef = useCallback((ref: () => void | undefined) => {
closeDataViewEditor.current = ref;
}, []);
const closeFlyout = useCallback(() => {
setIsFlyoutVisible(false);
}, []);
const { dataViewFieldEditor, dataViewEditor } = services;
const { dataViewEditor } = services;
const { availableFields$ } = props;
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
@ -269,140 +302,96 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
});
}, [selectedDataView, sidebarState.fieldCounts, props.columns, availableFields$]);
const editField = useMemo(
const canEditDataView =
Boolean(dataViewEditor?.userPermissions.editDataView()) ||
Boolean(selectedDataView && !selectedDataView.isPersisted());
const closeFieldListFlyout = unifiedFieldListSidebarContainerApi?.closeFieldListFlyout;
const createNewDataView = useMemo(
() =>
!isPlainRecord && canEditDataView && selectedDataView
? (fieldName?: string) => {
const ref = dataViewFieldEditor.openEditor({
ctx: {
dataView: selectedDataView,
},
fieldName,
onSave: async () => {
await onFieldEdited();
canEditDataView
? () => {
const ref = dataViewEditor.openEditor({
onSave: async (dataView) => {
onDataViewCreated(dataView);
},
});
if (setFieldEditorRef) {
setFieldEditorRef(ref);
}
if (closeFlyout) {
closeFlyout();
if (setDataViewEditorRef) {
setDataViewEditorRef(ref);
}
closeFieldListFlyout?.();
}
: undefined,
[
isPlainRecord,
canEditDataView,
dataViewFieldEditor,
selectedDataView,
setFieldEditorRef,
closeFlyout,
onFieldEdited,
]
[canEditDataView, dataViewEditor, setDataViewEditorRef, onDataViewCreated, closeFieldListFlyout]
);
const createNewDataView = useCallback(() => {
const ref = dataViewEditor.openEditor({
onSave: async (dataView) => {
onDataViewCreated(dataView);
},
});
if (setDataViewEditorRef) {
setDataViewEditorRef(ref);
}
if (closeFlyout) {
closeFlyout();
}
}, [dataViewEditor, setDataViewEditorRef, closeFlyout, onDataViewCreated]);
const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
() => ({
...services,
uiActions: getUiActions(),
}),
[services]
);
const searchBarCustomization = useDiscoverCustomization('search_bar');
const CustomDataViewPicker = searchBarCustomization?.CustomDataViewPicker;
const createField = unifiedFieldListSidebarContainerApi?.createField;
const prependDataViewPickerForMobile = useCallback(() => {
return selectedDataView ? (
CustomDataViewPicker ? (
<CustomDataViewPicker />
) : (
<DataViewPicker
currentDataViewId={selectedDataView.id}
onChangeDataView={onChangeDataView}
onAddField={createField}
onDataViewCreated={createNewDataView}
trigger={{
label: selectedDataView?.getName() || '',
'data-test-subj': 'dataView-switch-link',
title: selectedDataView?.getIndexPattern() || '',
fullWidth: true,
}}
/>
)
) : null;
}, [selectedDataView, createNewDataView, onChangeDataView, createField, CustomDataViewPicker]);
const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => {
onAddField(field.name);
},
[onAddField]
);
const onRemoveFieldFromWorkspace = useCallback(
(field: DataViewField) => {
onRemoveField(field.name);
},
[onRemoveField]
);
if (!selectedDataView) {
return null;
}
return (
<>
{!props.isClosed && (
<EuiHideFor sizes={['xs', 's']}>
<DiscoverSidebar
{...props}
isProcessing={isProcessing}
onFieldEdited={onFieldEdited}
allFields={sidebarState.allFields}
editField={editField}
createNewDataView={createNewDataView}
showFieldList={showFieldList}
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
/>
</EuiHideFor>
)}
<EuiShowFor sizes={['xs', 's']}>
<div className="dscSidebar__mobile">
<EuiButton
contentProps={{
className: 'dscSidebar__mobileButton',
id: DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields,
}}
fullWidth
onClick={() => setIsFlyoutVisible(true)}
>
<FormattedMessage
id="discover.fieldChooser.fieldsMobileButtonLabel"
defaultMessage="Fields"
/>
<EuiBadge
className="dscSidebar__mobileBadge"
color={props.columns[0] === '_source' ? 'default' : 'accent'}
>
{props.columns[0] === '_source' ? 0 : props.columns.length}
</EuiBadge>
</EuiButton>
</div>
{isFlyoutVisible && (
<EuiPortal>
<EuiFlyout
size="s"
onClose={() => setIsFlyoutVisible(false)}
aria-labelledby="flyoutTitle"
ownFocus
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutTitle">
<EuiLink color="text" onClick={() => setIsFlyoutVisible(false)}>
<EuiIcon
className="eui-alignBaseline"
aria-label={i18n.translate('discover.fieldList.flyoutBackIcon', {
defaultMessage: 'Back',
})}
type="arrowLeft"
/>{' '}
<strong>
{i18n.translate('discover.fieldList.flyoutHeading', {
defaultMessage: 'Field list',
})}
</strong>
</EuiLink>
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<DiscoverSidebar
{...props}
isProcessing={isProcessing}
onFieldEdited={onFieldEdited}
allFields={sidebarState.allFields}
alwaysShowActionButtons={true}
setFieldEditorRef={setFieldEditorRef}
closeFlyout={closeFlyout}
editField={editField}
createNewDataView={createNewDataView}
showDataViewPicker={true}
showFieldList={showFieldList}
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
/>
</EuiFlyout>
</EuiPortal>
)}
</EuiShowFor>
</>
<UnifiedFieldListSidebarContainer
ref={initializeUnifiedFieldListSidebarContainerApi}
variant={fieldListVariant}
getCreationOptions={getCreationOptions}
isSidebarCollapsed={props.isClosed}
services={fieldListSidebarServices}
dataView={selectedDataView}
trackUiMetric={trackUiMetric}
allFields={sidebarState.allFields}
showFieldList={showFieldList}
workspaceSelectedFieldNames={columns}
onAddFieldToWorkspace={onAddFieldToWorkspace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
prependInFlyout={prependDataViewPickerForMobile}
/>
);
}

View file

@ -6,5 +6,4 @@
* Side Public License, v 1.
*/
export { DiscoverSidebar } from './discover_sidebar';
export { DiscoverSidebarResponsive } from './discover_sidebar_responsive';

View file

@ -111,8 +111,8 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
);
});
it('should return examples for non-aggregatable fields', async () => {
await PageObjects.unifiedFieldList.clickFieldListItem('extension');
it('should return examples for non-aggregatable or geo fields', async () => {
await PageObjects.unifiedFieldList.clickFieldListItem('geo.coordinates');
expect(await PageObjects.unifiedFieldList.getFieldStatsViewType()).to.be('exampleValues');
expect(await PageObjects.unifiedFieldList.getFieldStatsDocsCount()).to.be(100);
// actual hits might vary

View file

@ -2345,20 +2345,14 @@
"discover.embeddable.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.",
"discover.embeddable.search.displayName": "rechercher",
"discover.errorCalloutShowErrorMessage": "Afficher les détails",
"discover.fieldChooser.addField.label": "Ajouter un champ",
"discover.fieldChooser.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.",
"discover.fieldChooser.discoverField.actions": "Actions",
"discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne",
"discover.fieldChooser.discoverField.multiField": "champ multiple",
"discover.fieldChooser.discoverField.multiFields": "Champs multiples",
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs.",
"discover.fieldChooser.discoverField.name": "Champ",
"discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau",
"discover.fieldChooser.discoverField.value": "Valeur",
"discover.fieldChooser.fieldsMobileButtonLabel": "Champs",
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "Index et champs",
"discover.fieldList.flyoutBackIcon": "Retour",
"discover.fieldList.flyoutHeading": "Liste des champs",
"discover.goToDiscoverButtonText": "Aller à Discover",
"discover.grid.closePopover": "Fermer la fenêtre contextuelle",
"discover.grid.copyCellValueButton": "Copier la valeur",

View file

@ -2345,20 +2345,14 @@
"discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。",
"discover.embeddable.search.displayName": "検索",
"discover.errorCalloutShowErrorMessage": "詳細を表示",
"discover.fieldChooser.addField.label": "フィールドを追加",
"discover.fieldChooser.availableFieldsTooltip": "フィールドをテーブルに表示できます。",
"discover.fieldChooser.discoverField.actions": "アクション",
"discover.fieldChooser.discoverField.addFieldTooltip": "フィールドを列として追加",
"discover.fieldChooser.discoverField.multiField": "複数フィールド",
"discover.fieldChooser.discoverField.multiFields": "マルチフィールド",
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "複数フィールドにはフィールドごとに複数の値を入力できます",
"discover.fieldChooser.discoverField.name": "フィールド",
"discover.fieldChooser.discoverField.removeFieldTooltip": "フィールドを表から削除",
"discover.fieldChooser.discoverField.value": "値",
"discover.fieldChooser.fieldsMobileButtonLabel": "フィールド",
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "インデックスとフィールド",
"discover.fieldList.flyoutBackIcon": "戻る",
"discover.fieldList.flyoutHeading": "フィールドリスト",
"discover.goToDiscoverButtonText": "Discoverに移動",
"discover.grid.closePopover": "ポップオーバーを閉じる",
"discover.grid.copyCellValueButton": "値をコピー",

View file

@ -2345,20 +2345,14 @@
"discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。",
"discover.embeddable.search.displayName": "搜索",
"discover.errorCalloutShowErrorMessage": "显示详情",
"discover.fieldChooser.addField.label": "添加字段",
"discover.fieldChooser.availableFieldsTooltip": "适用于在表中显示的字段。",
"discover.fieldChooser.discoverField.actions": "操作",
"discover.fieldChooser.discoverField.addFieldTooltip": "将字段添加为列",
"discover.fieldChooser.discoverField.multiField": "多字段",
"discover.fieldChooser.discoverField.multiFields": "多字段",
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "多字段的每个字段可以有多个值",
"discover.fieldChooser.discoverField.name": "字段",
"discover.fieldChooser.discoverField.removeFieldTooltip": "从表中移除字段",
"discover.fieldChooser.discoverField.value": "值",
"discover.fieldChooser.fieldsMobileButtonLabel": "字段",
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "索引和字段",
"discover.fieldList.flyoutBackIcon": "返回",
"discover.fieldList.flyoutHeading": "字段列表",
"discover.goToDiscoverButtonText": "前往 Discover",
"discover.grid.closePopover": "关闭弹出框",
"discover.grid.copyCellValueButton": "复制值",