[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", "dataViews",
"dataViewFieldEditor", "dataViewFieldEditor",
"charts", "charts",
"fieldFormats" "fieldFormats",
"uiActions"
] ]
} }
} }

View file

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

View file

@ -7,13 +7,11 @@
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/react';
import { import {
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiPage, EuiPage,
EuiPageBody, EuiPageBody,
EuiPageSidebar,
EuiTitle, EuiTitle,
EuiEmptyPrompt, EuiEmptyPrompt,
EuiLoadingLogo, EuiLoadingLogo,
@ -38,7 +36,7 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
const [dataView, setDataView] = useState<DataView | null>(); const [dataView, setDataView] = useState<DataView | null>();
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]); const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
const onAddFieldToWorkplace = useCallback( const onAddFieldToWorkspace = useCallback(
(field: DataViewField) => { (field: DataViewField) => {
setSelectedFieldNames((names) => [...names, field.name]); setSelectedFieldNames((names) => [...names, field.name]);
}, },
@ -124,20 +122,13 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
<RootDragDropProvider> <RootDragDropProvider>
<EuiFlexGroup direction="row" alignItems="stretch"> <EuiFlexGroup direction="row" alignItems="stretch">
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiPageSidebar <FieldListSidebar
css={css` services={services}
flex: 1; dataView={dataView}
width: 320px; selectedFieldNames={selectedFieldNames}
`} onAddFieldToWorkspace={onAddFieldToWorkspace}
> onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
<FieldListSidebar />
services={services}
dataView={dataView}
selectedFieldNames={selectedFieldNames}
onAddFieldToWorkplace={onAddFieldToWorkplace}
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
/>
</EuiPageSidebar>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem> <EuiFlexItem>
<ExampleDropZone onDropField={onDropFieldToWorkplace} /> <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. * Side Public License, v 1.
*/ */
import React, { useCallback, useContext, useMemo } from 'react'; import React, { useCallback, useContext, useMemo, useRef } from 'react';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; 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 { ChildDragDropProvider, DragContext } from '@kbn/dom-drag-drop';
import { import {
FieldList, UnifiedFieldListSidebarContainer,
FieldListFilters, type UnifiedFieldListSidebarContainerProps,
FieldListGrouped, type UnifiedFieldListSidebarContainerApi,
FieldListGroupedProps, type AddFieldFilterHandler,
FieldsGroupNames,
useExistingFieldsFetcher,
useGroupedFields,
useQuerySubscriber,
} from '@kbn/unified-field-list'; } 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 { export interface FieldListSidebarProps {
dataView: DataView; dataView: DataView;
selectedFieldNames: string[]; selectedFieldNames: string[];
services: FieldListItemProps['services']; services: AppPluginStartDependencies & {
onAddFieldToWorkplace: FieldListItemProps['onAddFieldToWorkspace']; core: CoreStart;
onRemoveFieldFromWorkspace: FieldListItemProps['onRemoveFieldFromWorkspace']; };
onAddFieldToWorkspace: UnifiedFieldListSidebarContainerProps['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: UnifiedFieldListSidebarContainerProps['onRemoveFieldFromWorkspace'];
} }
export const FieldListSidebar: React.FC<FieldListSidebarProps> = ({ export const FieldListSidebar: React.FC<FieldListSidebarProps> = ({
dataView, dataView,
selectedFieldNames, selectedFieldNames,
services, services,
onAddFieldToWorkplace, onAddFieldToWorkspace,
onRemoveFieldFromWorkspace, onRemoveFieldFromWorkspace,
}) => { }) => {
const dragDropContext = useContext(DragContext); const dragDropContext = useContext(DragContext);
const allFields = dataView.fields; const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
const activeDataViews = useMemo(() => [dataView], [dataView]); const filterManager = services.data?.query?.filterManager;
const querySubscriberResult = useQuerySubscriber({
data: services.data,
listenToSearchSessionUpdates: false, // this example app does not use search sessions
});
const onSelectedFieldFilter = useCallback( const onAddFilter: AddFieldFilterHandler | undefined = useMemo(
(field: DataViewField) => { () =>
return selectedFieldNames.includes(field.name); filterManager && dataView
}, ? (clickedField, values, operation) => {
[selectedFieldNames] const newFilters = generateFilters(
filterManager,
clickedField,
values,
operation,
dataView
);
filterManager.addFilters(newFilters);
}
: undefined,
[dataView, filterManager]
); );
const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({ const onFieldEdited = useCallback(async () => {
dataViews: activeDataViews, // if you need field existence info for more than one data view, you can specify it here unifiedFieldListContainerRef.current?.refetchFieldsExistenceInfo();
query: querySubscriberResult.query, }, [unifiedFieldListContainerRef]);
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,
]
);
return ( return (
<ChildDragDropProvider {...dragDropContext}> <ChildDragDropProvider {...dragDropContext}>
<FieldList <UnifiedFieldListSidebarContainer
isProcessing={isProcessing} ref={unifiedFieldListContainerRef}
prepend={<FieldListFilters {...fieldListFiltersProps} />} variant="responsive"
> getCreationOptions={getCreationOptions}
<FieldListGrouped services={services}
{...fieldListGroupedProps} dataView={dataView}
renderFieldItem={renderFieldItem} allFields={dataView.fields}
localStorageKeyPrefix="examples" workspaceSelectedFieldNames={selectedFieldNames}
/> onAddFieldToWorkspace={onAddFieldToWorkspace}
</FieldList> onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
onAddFilter={onAddFilter}
onFieldEdited={onFieldEdited}
/>
</ChildDragDropProvider> </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 { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-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 // eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface UnifiedFieldListExamplesPluginSetup {} export interface UnifiedFieldListExamplesPluginSetup {}
@ -32,4 +33,5 @@ export interface AppPluginStartDependencies {
unifiedSearch: UnifiedSearchPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart;
charts: ChartsPluginStart; charts: ChartsPluginStart;
fieldFormats: FieldFormatsStart; fieldFormats: FieldFormatsStart;
uiActions: UiActionsStart;
} }

View file

@ -28,5 +28,7 @@
"@kbn/field-formats-plugin", "@kbn/field-formats-plugin",
"@kbn/data-view-field-editor-plugin", "@kbn/data-view-field-editor-plugin",
"@kbn/unified-field-list", "@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", "id": "@kbn/dom-drag-drop",
"owner": [ "owner": [
"@elastic/kibana-visualizations", "@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). 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. * `<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. * `<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 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 ## Development
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. 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, FieldListItem,
GetCustomFieldType, GetCustomFieldType,
RenderFieldItemParams, RenderFieldItemParams,
SearchMode,
} from './src/types'; } from './src/types';
export { ExistenceFetchStatus, FieldsGroupNames } from './src/types'; export { ExistenceFetchStatus, FieldsGroupNames } from './src/types';
@ -80,6 +81,7 @@ export {
export { export {
useQuerySubscriber, useQuerySubscriber,
hasQuerySubscriberData, hasQuerySubscriberData,
getSearchMode,
type QuerySubscriberResult, type QuerySubscriberResult,
type QuerySubscriberParams, type QuerySubscriberParams,
} from './src/hooks/use_query_subscriber'; } from './src/hooks/use_query_subscriber';
@ -91,3 +93,9 @@ export {
getFieldType, getFieldType,
getFieldIconType, getFieldIconType,
} from './src/utils/field_types'; } 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 React from 'react';
import { findTestSubject } from '@elastic/eui/lib/test'; import { findTestSubject } from '@elastic/eui/lib/test';
import { mountWithIntl } from '@kbn/test-jest-helpers'; import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DiscoverField, DiscoverFieldProps } from './discover_field';
import { DataViewField } from '@kbn/data-views-plugin/public'; 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 { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container'; import { getServicesMock } from '../../../__mocks__/services.mock';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { UnifiedFieldListItem, UnifiedFieldListItemProps } from './field_list_item';
import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { FieldItemButton } from '../../components/field_item_button';
import { FieldItemButton } from '@kbn/unified-field-list'; 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({ loadFieldStats: jest.fn().mockResolvedValue({
totalDocuments: 1624, totalDocuments: 1624,
sampledDocuments: 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({ async function getComponent({
selected = false, selected = false,
field, field,
onAddFilterExists = true, canFilter = true,
}: { }: {
selected?: boolean; selected?: boolean;
field?: DataViewField; field?: DataViewField;
onAddFilterExists?: boolean; canFilter?: boolean;
}) { }) {
const finalField = const finalField =
field ?? field ??
@ -72,62 +62,45 @@ async function getComponent({
const dataView = stubDataView; const dataView = stubDataView;
dataView.toSpec = () => ({}); dataView.toSpec = () => ({});
const props: DiscoverFieldProps = { const stateService = createStateService({
options: {
originatingApp: 'test',
},
});
const props: UnifiedFieldListItemProps = {
services: getServicesMock(),
stateService,
searchMode: 'documents',
dataView: stubDataView, dataView: stubDataView,
field: finalField, field: finalField,
...(onAddFilterExists && { onAddFilter: jest.fn() }), ...(canFilter && { onAddFilter: jest.fn() }),
onAddField: jest.fn(), onAddFieldToWorkspace: jest.fn(),
onRemoveFieldFromWorkspace: jest.fn(),
onEditField: jest.fn(), onEditField: jest.fn(),
onRemoveField: jest.fn(),
isSelected: selected, isSelected: selected,
isEmpty: false, isEmpty: false,
groupIndex: 1, groupIndex: 1,
itemIndex: 0, itemIndex: 0,
contextualFields: [], workspaceSelectedFieldNames: [],
}; };
const services = { const comp = await mountWithIntl(<UnifiedFieldListItem {...props} />);
...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>
);
// wait for lazy modules // wait for lazy modules
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
await comp.update(); await comp.update();
return { comp, props }; return { comp, props };
} }
describe('discover sidebar field', function () { describe('UnifiedFieldListItem', function () {
it('should allow selecting fields', async function () { it('should allow selecting fields', async function () {
const { comp, props } = await getComponent({}); const { comp, props } = await getComponent({});
findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
expect(props.onAddField).toHaveBeenCalledWith('bytes'); expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(props.field);
}); });
it('should allow deselecting fields', async function () { it('should allow deselecting fields', async function () {
const { comp, props } = await getComponent({ selected: true }); const { comp, props } = await getComponent({ selected: true });
findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); 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 () { it('displays warning for conflicting fields', async function () {
const field = new DataViewField({ const field = new DataViewField({
@ -157,7 +130,7 @@ describe('discover sidebar field', function () {
const { comp } = await getComponent({ const { comp } = await getComponent({
selected: true, selected: true,
field, field,
onAddFilterExists: false, canFilter: false,
}); });
expect(comp.find(FieldItemButton).prop('onClick')).toBeUndefined(); expect(comp.find(FieldItemButton).prop('onClick')).toBeUndefined();
@ -171,7 +144,7 @@ describe('discover sidebar field', function () {
searchable: true, searchable: true,
}); });
const { comp } = await getComponent({ field, onAddFilterExists: true }); const { comp } = await getComponent({ field, canFilter: true });
await act(async () => { await act(async () => {
const fieldItem = findTestSubject(comp, 'field-machine.os.raw-showDetails'); 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 new Promise((resolve) => setTimeout(resolve, 0));
await comp.update(); await comp.update();
expect(findTestSubject(comp, 'dscFieldStats-title').text()).toBe('Top values'); expect(findTestSubject(comp, 'fieldStats-title').text()).toBe('Top values');
expect(findTestSubject(comp, 'dscFieldStats-topValues-bucket')).toHaveLength(2); expect(findTestSubject(comp, 'fieldStats-topValues-bucket')).toHaveLength(2);
expect( expect(findTestSubject(comp, 'fieldStats-topValues-formattedFieldValue').first().text()).toBe(
findTestSubject(comp, 'dscFieldStats-topValues-formattedFieldValue').first().text() 'osx'
).toBe('osx'); );
expect(comp.find(EuiProgress)).toHaveLength(2); 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 () { it('should include popover actions', async function () {
const field = new DataViewField({ const field = new DataViewField({
@ -203,7 +176,7 @@ describe('discover sidebar field', function () {
searchable: true, searchable: true,
}); });
const { comp, props } = await getComponent({ field, onAddFilterExists: true }); const { comp, props } = await getComponent({ field, canFilter: true });
await act(async () => { await act(async () => {
const fieldItem = findTestSubject(comp, 'field-extension.keyword-showDetails'); 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() comp.find('[data-test-subj="fieldPopoverHeader_addField-extension.keyword"]').exists()
).toBeTruthy(); ).toBeTruthy();
expect( expect(
comp comp.find('[data-test-subj="fieldPopoverHeader_addExistsFilter-extension.keyword"]').exists()
.find('[data-test-subj="discoverFieldListPanelAddExistFilter-extension.keyword"]')
.exists()
).toBeTruthy(); ).toBeTruthy();
expect( expect(
comp.find('[data-test-subj="discoverFieldListPanelEdit-extension.keyword"]').exists() comp.find('[data-test-subj="fieldPopoverHeader_editField-extension.keyword"]').exists()
).toBeTruthy(); ).toBeTruthy();
expect( expect(
comp.find('[data-test-subj="discoverFieldListPanelDelete-extension.keyword"]').exists() comp.find('[data-test-subj="fieldPopoverHeader_deleteField-extension.keyword"]').exists()
).toBeFalsy(); ).toBeFalsy();
await act(async () => { await act(async () => {
@ -235,7 +206,7 @@ describe('discover sidebar field', function () {
await comp.update(); await comp.update();
}); });
expect(props.onAddField).toHaveBeenCalledWith('extension.keyword'); expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(field);
await comp.update(); await comp.update();
@ -253,7 +224,7 @@ describe('discover sidebar field', function () {
const { comp } = await getComponent({ const { comp } = await getComponent({
field, field,
onAddFilterExists: true, canFilter: true,
selected: true, selected: true,
}); });

View file

@ -6,41 +6,47 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
import './discover_field.scss';
import React, { memo, useCallback, useMemo, useState } from 'react'; import React, { memo, useCallback, useMemo, useState } from 'react';
import { EuiSpacer, EuiTitle } from '@elastic/eui'; import { EuiSpacer, EuiTitle } from '@elastic/eui';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics'; import { UiCounterMetricType } from '@kbn/analytics';
import { DragDrop } from '@kbn/dom-drag-drop';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; 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 { import {
FieldItemButton,
type FieldItemButtonProps,
FieldPopover, FieldPopover,
FieldPopoverHeader, FieldPopoverHeader,
FieldPopoverHeaderProps, type FieldPopoverHeaderProps,
FieldPopoverFooter, FieldPopoverFooter,
} from '@kbn/unified-field-list'; type FieldPopoverFooterProps,
import { DragDrop } from '@kbn/dom-drag-drop'; } from '../../components/field_popover';
import { DiscoverFieldStats } from './discover_field_stats'; import {
import { PLUGIN_ID } from '../../../../../common'; UnifiedFieldListItemStats,
import { getUiActions } from '../../../../kibana_services'; type UnifiedFieldListItemStatsProps,
} from './field_list_item_stats';
import type {
UnifiedFieldListSidebarContainerStateService,
AddFieldFilterHandler,
} from '../../types';
interface GetCommonFieldItemButtonPropsParams { interface GetCommonFieldItemButtonPropsParams {
stateService: UnifiedFieldListSidebarContainerStateService;
field: DataViewField; field: DataViewField;
isSelected: boolean; isSelected: boolean;
toggleDisplay: (field: DataViewField, isSelected?: boolean) => void; toggleDisplay: (field: DataViewField, isSelected?: boolean) => void;
} }
function getCommonFieldItemButtonProps({ function getCommonFieldItemButtonProps({
stateService,
field, field,
isSelected, isSelected,
toggleDisplay, toggleDisplay,
}: GetCommonFieldItemButtonPropsParams): { }: GetCommonFieldItemButtonPropsParams): {
field: FieldItemButtonProps<DataViewField>['field']; field: FieldItemButtonProps<DataViewField>['field'];
isSelected: FieldItemButtonProps<DataViewField>['isSelected']; isSelected: FieldItemButtonProps<DataViewField>['isSelected'];
buttonAddFieldToWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps']; buttonAddFieldToWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
buttonRemoveFieldFromWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps']; buttonRemoveFieldFromWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace']; onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace'];
onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace']; onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace'];
} { } {
@ -49,33 +55,27 @@ function getCommonFieldItemButtonProps({
return { return {
field, field,
isSelected, isSelected,
buttonAddFieldToWorkspaceProps: { buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', { buttonRemoveFieldFromWorkspaceProps:
defaultMessage: 'Add field as column', stateService.creationOptions.buttonRemoveFieldFromWorkspaceProps,
}),
},
buttonRemoveFieldFromWorkspaceProps: {
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
defaultMessage: 'Remove field from table',
}),
},
onAddFieldToWorkspace: handler, onAddFieldToWorkspace: handler,
onRemoveFieldFromWorkspace: handler, onRemoveFieldFromWorkspace: handler,
}; };
} }
interface MultiFieldsProps { interface MultiFieldsProps {
multiFields: NonNullable<DiscoverFieldProps['multiFields']>; stateService: UnifiedFieldListSidebarContainerStateService;
multiFields: NonNullable<UnifiedFieldListItemProps['multiFields']>;
toggleDisplay: (field: DataViewField) => void; toggleDisplay: (field: DataViewField) => void;
alwaysShowActionButton: boolean; alwaysShowActionButton: boolean;
} }
const MultiFields: React.FC<MultiFieldsProps> = memo( const MultiFields: React.FC<MultiFieldsProps> = memo(
({ multiFields, toggleDisplay, alwaysShowActionButton }) => ( ({ stateService, multiFields, toggleDisplay, alwaysShowActionButton }) => (
<React.Fragment> <React.Fragment>
<EuiTitle size="xxxs"> <EuiTitle size="xxxs">
<h5> <h5>
{i18n.translate('discover.fieldChooser.discoverField.multiFields', { {i18n.translate('unifiedFieldList.fieldListItem.multiFields', {
defaultMessage: 'Multi fields', defaultMessage: 'Multi fields',
})} })}
</h5> </h5>
@ -85,13 +85,13 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
<FieldItemButton <FieldItemButton
key={entry.field.name} key={entry.field.name}
size="xs" size="xs"
className="dscSidebarItem dscSidebarItem--multi"
flush="both" flush="both"
isEmpty={false} isEmpty={false}
isActive={false} isActive={false}
shouldAlwaysShowAction={alwaysShowActionButton} shouldAlwaysShowAction={alwaysShowActionButton}
onClick={undefined} onClick={undefined}
{...getCommonFieldItemButtonProps({ {...getCommonFieldItemButtonProps({
stateService,
field: entry.field, field: entry.field,
isSelected: entry.isSelected, isSelected: entry.isSelected,
toggleDisplay, 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 * Determines whether add/remove button is displayed not only when focused
*/ */
@ -118,16 +133,16 @@ export interface DiscoverFieldProps {
/** /**
* Callback to add/select the field * Callback to add/select the field
*/ */
onAddField: (fieldName: string) => void; onAddFieldToWorkspace: (field: DataViewField) => void;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: DataViewField | string, value: unknown, type: '+' | '-') => void;
/** /**
* Callback to remove a field column from the table * Callback to remove a field column from the table
* @param fieldName * @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 * Determines whether the field is empty
*/ */
@ -142,49 +157,48 @@ export interface DiscoverFieldProps {
* @param eventName * @param eventName
*/ */
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
/**
* Multi fields for the current field
*/
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>; multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
/** /**
* Callback to edit a field from data view * Callback to edit a field from data view
* @param fieldName name of the field to edit * @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 * Callback to delete a runtime field from data view
* @param fieldName name of the field to delete * @param fieldName name of the field to delete
*/ */
onDeleteField?: (fieldName: string) => void; onDeleteField?: (fieldName: string) => void;
/** /**
* Columns * Currently selected fields like table columns
*/ */
contextualFields: string[]; workspaceSelectedFieldNames?: string[];
/** /**
* Search by field name * Search by field name
*/ */
highlight?: string; highlight?: string;
/** /**
* Group index in the field list * Group index in the field list
*/ */
groupIndex: number; groupIndex: number;
/** /**
* Item index in the field list * Item index in the field list
*/ */
itemIndex: number; itemIndex: number;
} }
function DiscoverFieldComponent({ function UnifiedFieldListItemComponent({
stateService,
services,
searchMode,
alwaysShowActionButton = false, alwaysShowActionButton = false,
field, field,
highlight, highlight,
dataView, dataView,
onAddField, onAddFieldToWorkspace,
onRemoveField, onRemoveFieldFromWorkspace,
onAddFilter, onAddFilter,
isEmpty, isEmpty,
isSelected, isSelected,
@ -192,12 +206,11 @@ function DiscoverFieldComponent({
multiFields, multiFields,
onEditField, onEditField,
onDeleteField, onDeleteField,
contextualFields, workspaceSelectedFieldNames,
groupIndex, groupIndex,
itemIndex, itemIndex,
}: DiscoverFieldProps) { }: UnifiedFieldListItemProps) {
const [infoIsOpen, setOpen] = useState(false); const [infoIsOpen, setOpen] = useState(false);
const isDocumentRecord = !!onAddFilter;
const addFilterAndClosePopover: typeof onAddFilter | undefined = useMemo( const addFilterAndClosePopover: typeof onAddFilter | undefined = useMemo(
() => () =>
@ -222,40 +235,41 @@ function DiscoverFieldComponent({
(f, isCurrentlySelected) => { (f, isCurrentlySelected) => {
closePopover(); closePopover();
if (isCurrentlySelected) { if (isCurrentlySelected) {
onRemoveField(f.name); onRemoveFieldFromWorkspace(f);
} else { } else {
onAddField(f.name); onAddFieldToWorkspace(f);
} }
}, },
[onAddField, onRemoveField, closePopover] [onAddFieldToWorkspace, onRemoveFieldFromWorkspace, closePopover]
); );
const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]); const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]);
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo( const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(() => {
() => ({ const dataTestSubjPrefix =
buttonAddFieldToWorkspaceProps: { stateService.creationOptions.dataTestSubj?.fieldListItemPopoverHeaderDataTestSubjPrefix;
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', { return {
defaultMessage: 'Add field as column', buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
}), ...(dataTestSubjPrefix && {
}, buttonAddFilterProps: {
buttonAddFilterProps: { 'data-test-subj': `${dataTestSubjPrefix}AddExistFilter-${field.name}`,
'data-test-subj': `discoverFieldListPanelAddExistFilter-${field.name}`, },
}, buttonEditFieldProps: {
buttonEditFieldProps: { 'data-test-subj': `${dataTestSubjPrefix}Edit-${field.name}`,
'data-test-subj': `discoverFieldListPanelEdit-${field.name}`, },
}, buttonDeleteFieldProps: {
buttonDeleteFieldProps: { 'data-test-subj': `${dataTestSubjPrefix}Delete-${field.name}`,
'data-test-subj': `discoverFieldListPanelDelete-${field.name}`, },
}, }),
}), };
[field.name] }, [field.name, stateService.creationOptions]);
);
const renderPopover = () => { const renderPopover = () => {
return ( return (
<> <>
<DiscoverFieldStats <UnifiedFieldListItemStats
stateService={stateService}
services={services}
field={field} field={field}
multiFields={multiFields} multiFields={multiFields}
dataView={dataView} dataView={dataView}
@ -266,6 +280,7 @@ function DiscoverFieldComponent({
<> <>
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<MultiFields <MultiFields
stateService={stateService}
multiFields={multiFields} multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton} alwaysShowActionButton={alwaysShowActionButton}
toggleDisplay={toggleDisplay} toggleDisplay={toggleDisplay}
@ -273,16 +288,18 @@ function DiscoverFieldComponent({
</> </>
)} )}
<FieldPopoverFooter {!!services.uiActions && (
field={field} <FieldPopoverFooter
dataView={dataView} field={field}
multiFields={rawMultiFields} dataView={dataView}
trackUiMetric={trackUiMetric} multiFields={rawMultiFields}
contextualFields={contextualFields} trackUiMetric={trackUiMetric}
originatingApp={PLUGIN_ID} contextualFields={workspaceSelectedFieldNames}
uiActions={getUiActions()} originatingApp={stateService.creationOptions.originatingApp}
closePopover={() => closePopover()} uiActions={services.uiActions}
/> closePopover={() => closePopover()}
/>
)}
</> </>
); );
}; };
@ -308,24 +325,28 @@ function DiscoverFieldComponent({
order={order} order={order}
value={value} value={value}
onDragStart={closePopover} onDragStart={closePopover}
isDisabled={alwaysShowActionButton} isDisabled={
dataTestSubj={`dscFieldListPanelField-${field.name}`} alwaysShowActionButton || stateService.creationOptions.disableFieldListItemDragAndDrop
}
dataTestSubj={`${
stateService.creationOptions.dataTestSubj?.fieldListItemDndDataTestSubjPrefix ??
'unifiedFieldListItemDnD'
}-${field.name}`}
> >
<FieldItemButton <FieldItemButton
size="xs" size="xs"
fieldSearchHighlight={highlight} fieldSearchHighlight={highlight}
className="dscSidebarItem"
isEmpty={isEmpty} isEmpty={isEmpty}
isActive={infoIsOpen} isActive={infoIsOpen}
flush={alwaysShowActionButton ? 'both' : undefined} flush={alwaysShowActionButton ? 'both' : undefined}
shouldAlwaysShowAction={alwaysShowActionButton} shouldAlwaysShowAction={alwaysShowActionButton}
onClick={field.type !== '_source' ? togglePopover : undefined} onClick={field.type !== '_source' ? togglePopover : undefined}
{...getCommonFieldItemButtonProps({ field, isSelected, toggleDisplay })} {...getCommonFieldItemButtonProps({ stateService, field, isSelected, toggleDisplay })}
/> />
</DragDrop> </DragDrop>
} }
closePopover={closePopover} closePopover={closePopover}
data-test-subj="discoverFieldListPanelPopover" data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemPopoverDataTestSubj}
renderHeader={() => ( renderHeader={() => (
<FieldPopoverHeader <FieldPopoverHeader
field={field} field={field}
@ -337,9 +358,9 @@ function DiscoverFieldComponent({
{...customPopoverHeaderProps} {...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 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 { import {
useQuerySubscriber, FieldStats,
hasQuerySubscriberData, type FieldStatsProps,
} from '@kbn/unified-field-list/src/hooks/use_query_subscriber'; type FieldStatsServices,
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public'; } from '../../components/field_stats';
import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { useQuerySubscriber, hasQuerySubscriberData } from '../../hooks/use_query_subscriber';
import type { UnifiedFieldListSidebarContainerStateService } from '../../types';
export interface DiscoverFieldStatsProps { export interface UnifiedFieldListItemStatsProps {
stateService: UnifiedFieldListSidebarContainerStateService;
field: DataViewField; field: DataViewField;
services: Omit<FieldStatsServices, 'uiSettings'> & {
core: CoreStart;
};
dataView: DataView; dataView: DataView;
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>; multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
onAddFilter: FieldStatsProps['onAddFilter']; onAddFilter: FieldStatsProps['onAddFilter'];
} }
export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo( export const UnifiedFieldListItemStats: React.FC<UnifiedFieldListItemStatsProps> = React.memo(
({ field, dataView, multiFields, onAddFilter }) => { ({ stateService, services, field, dataView, multiFields, onAddFilter }) => {
const services = useDiscoverServices();
const querySubscriberResult = useQuerySubscriber({ const querySubscriberResult = useQuerySubscriber({
data: services.data, data: services.data,
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
}); });
// prioritize an aggregatable multi field if available or take the parent field // prioritize an aggregatable multi field if available or take the parent field
const fieldForStats = useMemo( const fieldForStats = useMemo(
@ -37,20 +43,31 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
[field, multiFields] [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)) { if (!hasQuerySubscriberData(querySubscriberResult)) {
return null; return null;
} }
return ( return (
<FieldStats <FieldStats
services={services} services={statsServices}
query={querySubscriberResult.query} query={querySubscriberResult.query}
filters={querySubscriberResult.filters} filters={querySubscriberResult.filters}
fromDate={querySubscriberResult.fromDate} fromDate={querySubscriberResult.fromDate}
toDate={querySubscriberResult.toDate} toDate={querySubscriberResult.toDate}
dataViewOrDataViewId={dataView} dataViewOrDataViewId={dataView}
field={fieldForStats} field={fieldForStats}
data-test-subj="dscFieldStats" data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemStatsDataTestSubj}
onAddFilter={onAddFilter} 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; overflow: hidden;
margin: 0 !important; margin: 0 !important;
flex-grow: 1; flex-grow: 1;
@ -13,7 +13,7 @@
} }
} }
.dscSidebar__list { .unifiedFieldListSidebar__list {
padding: $euiSizeS 0 $euiSizeS $euiSizeS; padding: $euiSizeS 0 $euiSizeS $euiSizeS;
@include euiBreakpoint('xs', 's') { @include euiBreakpoint('xs', 's') {
@ -21,20 +21,20 @@
} }
} }
.dscSidebar__group { .unifiedFieldListSidebar__group {
height: 100%; height: 100%;
} }
.dscSidebar__mobile { .unifiedFieldListSidebar__mobile {
width: 100%; width: 100%;
padding: $euiSizeS $euiSizeS 0; padding: $euiSizeS $euiSizeS 0;
.dscSidebar__mobileBadge { .unifiedFieldListSidebar__mobileBadge {
margin-left: $euiSizeS; margin-left: $euiSizeS;
vertical-align: text-bottom; vertical-align: text-bottom;
} }
} }
.dscSidebar__flyoutHeader { .unifiedFieldListSidebar__flyoutHeader {
align-items: center; 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 () { it('should pick fields as unknown_selected if they are unknown', function () {
const actual = getSelectedFields({ const actual = getSelectedFields({
dataView, dataView,
columns: ['currency'], workspaceSelectedFieldNames: ['currency'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual).toMatchInlineSnapshot(` expect(actual).toMatchInlineSnapshot(`
Object { Object {
@ -37,14 +37,14 @@ describe('group_fields', function () {
it('should pick fields as nested for a nested field root', function () { it('should pick fields as nested for a nested field root', function () {
const actual = getSelectedFields({ const actual = getSelectedFields({
dataView, dataView,
columns: ['nested1', 'bytes'], workspaceSelectedFieldNames: ['nested1', 'bytes'],
allFields: [ allFields: [
{ {
name: 'nested1', name: 'nested1',
type: 'nested', type: 'nested',
}, },
] as DataViewField[], ] as DataViewField[],
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual.selectedFieldsMap).toMatchInlineSnapshot(` expect(actual.selectedFieldsMap).toMatchInlineSnapshot(`
Object { Object {
@ -56,14 +56,19 @@ describe('group_fields', function () {
it('should work correctly if no columns selected', function () { it('should work correctly if no columns selected', function () {
expect( expect(
getSelectedFields({ dataView, columns: [], allFields: dataView.fields, isPlainRecord: false }) getSelectedFields({
dataView,
workspaceSelectedFieldNames: [],
allFields: dataView.fields,
searchMode: 'documents',
})
).toBe(INITIAL_SELECTED_FIELDS_RESULT); ).toBe(INITIAL_SELECTED_FIELDS_RESULT);
expect( expect(
getSelectedFields({ getSelectedFields({
dataView, dataView,
columns: ['_source'], workspaceSelectedFieldNames: ['_source'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}) })
).toBe(INITIAL_SELECTED_FIELDS_RESULT); ).toBe(INITIAL_SELECTED_FIELDS_RESULT);
}); });
@ -71,9 +76,9 @@ describe('group_fields', function () {
it('should pick fields into selected group', function () { it('should pick fields into selected group', function () {
const actual = getSelectedFields({ const actual = getSelectedFields({
dataView, dataView,
columns: ['bytes', '@timestamp'], workspaceSelectedFieldNames: ['bytes', '@timestamp'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']); expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
expect(actual.selectedFieldsMap).toStrictEqual({ expect(actual.selectedFieldsMap).toStrictEqual({
@ -85,9 +90,9 @@ describe('group_fields', function () {
it('should pick fields into selected group if they contain multifields', function () { it('should pick fields into selected group if they contain multifields', function () {
const actual = getSelectedFields({ const actual = getSelectedFields({
dataView, dataView,
columns: ['machine.os', 'machine.os.raw'], workspaceSelectedFieldNames: ['machine.os', 'machine.os.raw'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual.selectedFields.map((field) => field.name)).toEqual([ expect(actual.selectedFields.map((field) => field.name)).toEqual([
'machine.os', 'machine.os',
@ -102,9 +107,9 @@ describe('group_fields', function () {
it('should sort selected fields by columns order', function () { it('should sort selected fields by columns order', function () {
const actual1 = getSelectedFields({ const actual1 = getSelectedFields({
dataView, dataView,
columns: ['bytes', 'extension.keyword', 'unknown'], workspaceSelectedFieldNames: ['bytes', 'extension.keyword', 'unknown'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual1.selectedFields.map((field) => field.name)).toEqual([ expect(actual1.selectedFields.map((field) => field.name)).toEqual([
'bytes', 'bytes',
@ -119,9 +124,9 @@ describe('group_fields', function () {
const actual2 = getSelectedFields({ const actual2 = getSelectedFields({
dataView, dataView,
columns: ['extension', 'bytes', 'unknown'], workspaceSelectedFieldNames: ['extension', 'bytes', 'unknown'],
allFields: dataView.fields, allFields: dataView.fields,
isPlainRecord: false, searchMode: 'documents',
}); });
expect(actual2.selectedFields.map((field) => field.name)).toEqual([ expect(actual2.selectedFields.map((field) => field.name)).toEqual([
'extension', '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 () { it('should pick fields only from allFields instead of data view fields for a text based query', function () {
const actual = getSelectedFields({ const actual = getSelectedFields({
dataView, dataView,
columns: ['bytes'], workspaceSelectedFieldNames: ['bytes'],
allFields: [ allFields: [
{ {
name: 'bytes', name: 'bytes',
type: 'text', type: 'text',
}, },
] as DataViewField[], ] as DataViewField[],
isPlainRecord: true, searchMode: 'text-based',
}); });
expect(actual).toMatchInlineSnapshot(` expect(actual).toMatchInlineSnapshot(`
Object { Object {
@ -163,30 +168,38 @@ describe('group_fields', function () {
}); });
it('should show any fields if for text-based searches', function () { it('should show any fields if for text-based searches', function () {
expect(shouldShowField(dataView.getFieldByName('bytes'), true)).toBe(true); expect(shouldShowField(dataView.getFieldByName('bytes'), 'text-based', false)).toBe(true);
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, true)).toBe(true); expect(
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, true)).toBe(false); 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 () { it('should show fields excluding subfields', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true); expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', false)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false); expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', false)).toBe(
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
true
);
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
false 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 () { it('should show fields including subfields', function () {
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true); expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', true)).toBe(true);
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false); expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', true)).toBe(
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
true true
); );
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe( expect(
false 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, type DataView,
getFieldSubtypeMulti, getFieldSubtypeMulti,
} from '@kbn/data-views-plugin/public'; } 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') { if (!field?.type || field.type === '_source') {
return false; return false;
} }
if (isPlainRecord) { if (searchMode === 'text-based') {
// exclude only `_source` for plain records // exclude only `_source` for plain records
return true; return true;
} }
if (disableMultiFieldsGroupingByParent) {
// include subfields
return true;
}
// exclude subfields // exclude subfields
return !getFieldSubtypeMulti(field?.spec); return !getFieldSubtypeMulti(field?.spec);
} }
@ -38,31 +47,36 @@ export interface SelectedFieldsResult {
export function getSelectedFields({ export function getSelectedFields({
dataView, dataView,
columns, workspaceSelectedFieldNames,
allFields, allFields,
isPlainRecord, searchMode,
}: { }: {
dataView: DataView | undefined; dataView: DataView | undefined;
columns: string[]; workspaceSelectedFieldNames?: string[];
allFields: DataViewField[] | null; allFields: DataViewField[] | null;
isPlainRecord: boolean; searchMode: SearchMode | undefined;
}): SelectedFieldsResult { }): SelectedFieldsResult {
const result: SelectedFieldsResult = { const result: SelectedFieldsResult = {
selectedFields: [], selectedFields: [],
selectedFieldsMap: {}, selectedFieldsMap: {},
}; };
if (!Array.isArray(columns) || !columns.length || !allFields) { if (
!workspaceSelectedFieldNames ||
!Array.isArray(workspaceSelectedFieldNames) ||
!workspaceSelectedFieldNames.length ||
!allFields
) {
return INITIAL_SELECTED_FIELDS_RESULT; return INITIAL_SELECTED_FIELDS_RESULT;
} }
// add selected columns, that are not part of the data view, to be removable // add selected field names, that are not part of the data view, to be removable
for (const column of columns) { for (const selectedFieldName of workspaceSelectedFieldNames) {
const selectedField = const selectedField =
(!isPlainRecord && dataView?.getFieldByName?.(column)) || (searchMode === 'documents' && dataView?.getFieldByName?.(selectedFieldName)) ||
allFields.find((field) => field.name === column) || // for example to pick a `nested` root field or find a selected field in text-based response 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, name: selectedFieldName,
displayName: column, displayName: selectedFieldName,
type: 'unknown_selected', type: 'unknown_selected',
} as DataViewField); } as DataViewField);
result.selectedFields.push(selectedField); 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 DataViewsContract } from '@kbn/data-views-plugin/public';
import { import {
type FieldListGroups, type FieldListGroups,
type FieldsGroupDetails,
type FieldsGroup, type FieldsGroup,
type FieldListItem, type FieldListItem,
type OverrideFieldGroupDetails,
FieldsGroupNames, FieldsGroupNames,
ExistenceFetchStatus, ExistenceFetchStatus,
} from '../types'; } from '../types';
@ -38,9 +38,7 @@ export interface GroupedFieldsParams<T extends FieldListItem> {
popularFieldsLimit?: number; popularFieldsLimit?: number;
sortedSelectedFields?: T[]; sortedSelectedFields?: T[];
getCustomFieldType?: FieldFiltersParams<T>['getCustomFieldType']; getCustomFieldType?: FieldFiltersParams<T>['getCustomFieldType'];
onOverrideFieldGroupDetails?: ( onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
groupName: FieldsGroupNames
) => Partial<FieldsGroupDetails> | undefined | null;
onSupportedFieldFilter?: (field: T) => boolean; onSupportedFieldFilter?: (field: T) => boolean;
onSelectedFieldFilter?: (field: T) => boolean; onSelectedFieldFilter?: (field: T) => boolean;
} }

View file

@ -9,14 +9,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query'; 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 { getResolvedDateRange } from '../utils/get_resolved_date_range';
import type { TimeRangeUpdatesType, SearchMode } from '../types';
/** /**
* Hook params * Hook params
*/ */
export interface QuerySubscriberParams { export interface QuerySubscriberParams {
data: DataPublicPluginStart; 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; filters: Filter[] | undefined;
fromDate: string | undefined; fromDate: string | undefined;
toDate: string | undefined; toDate: string | undefined;
searchMode: SearchMode | undefined;
} }
/** /**
* Memorizes current query, filters and absolute date range * Memorizes current query, filters and absolute date range
* @param data * @param data
* @param listenToSearchSessionUpdates * @param timeRangeUpdatesType
* @public * @public
*/ */
export const useQuerySubscriber = ({ export const useQuerySubscriber = ({
data, data,
listenToSearchSessionUpdates = true, timeRangeUpdatesType = 'search-session',
}: QuerySubscriberParams) => { }: QuerySubscriberParams) => {
const timefilter = data.query.timefilter.timefilter; const timefilter = data.query.timefilter.timefilter;
const [result, setResult] = useState<QuerySubscriberResult>(() => { const [result, setResult] = useState<QuerySubscriberResult>(() => {
@ -48,11 +54,12 @@ export const useQuerySubscriber = ({
filters: state?.filters, filters: state?.filters,
fromDate: dateRange.fromDate, fromDate: dateRange.fromDate,
toDate: dateRange.toDate, toDate: dateRange.toDate,
searchMode: getSearchMode(state?.query),
}; };
}); });
useEffect(() => { useEffect(() => {
if (!listenToSearchSessionUpdates) { if (timeRangeUpdatesType !== 'search-session') {
return; return;
} }
@ -66,10 +73,10 @@ export const useQuerySubscriber = ({
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [setResult, timefilter, data.search.session.state$, listenToSearchSessionUpdates]); }, [setResult, timefilter, data.search.session.state$, timeRangeUpdatesType]);
useEffect(() => { useEffect(() => {
if (listenToSearchSessionUpdates) { if (timeRangeUpdatesType !== 'timefilter') {
return; return;
} }
@ -83,7 +90,7 @@ export const useQuerySubscriber = ({
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [setResult, timefilter, listenToSearchSessionUpdates]); }, [setResult, timefilter, timeRangeUpdatesType]);
useEffect(() => { useEffect(() => {
const subscription = data.query.state$.subscribe(({ state, changes }) => { const subscription = data.query.state$.subscribe(({ state, changes }) => {
@ -92,6 +99,7 @@ export const useQuerySubscriber = ({
...prevState, ...prevState,
query: state.query, query: state.query,
filters: state.filters, filters: state.filters,
searchMode: getSearchMode(state.query),
})); }));
} }
}); });
@ -114,4 +122,25 @@ export const hasQuerySubscriberData = (
filters: Filter[]; filters: Filter[];
fromDate: string; fromDate: string;
toDate: 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 { DataViewField } from '@kbn/data-views-plugin/common';
import type { EuiButtonIconProps, EuiButtonProps } from '@elastic/eui';
export interface BucketedAggregation<KeyType = string> { export interface BucketedAggregation<KeyType = string> {
buckets: Array<{ buckets: Array<{
@ -103,3 +104,93 @@ export interface RenderFieldItemParams<T extends FieldListItem> {
groupName: FieldsGroupNames; groupName: FieldsGroupNames;
fieldSearchHighlight?: string; 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": { "compilerOptions": {
"outDir": "target/types" "outDir": "target/types"
}, },
"include": ["*.ts", "src/**/*"], "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"],
"kbn_references": [ "kbn_references": [
"@kbn/i18n", "@kbn/i18n",
"@kbn/data-views-plugin", "@kbn/data-views-plugin",
@ -24,6 +24,9 @@
"@kbn/field-types", "@kbn/field-types",
"@kbn/ui-actions-browser", "@kbn/ui-actions-browser",
"@kbn/data-service", "@kbn/data-service",
"@kbn/data-view-field-editor-plugin",
"@kbn/dom-drag-drop",
"@kbn/shared-ux-utility",
], ],
"exclude": ["target/**/*"] "exclude": ["target/**/*"]
} }

View file

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

View file

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

View file

@ -289,9 +289,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
selectedDataView={dataView} selectedDataView={dataView}
isClosed={isSidebarClosed} isClosed={isSidebarClosed}
trackUiMetric={trackUiMetric} trackUiMetric={trackUiMetric}
useNewFieldsApi={useNewFieldsApi}
onFieldEdited={onFieldEdited} onFieldEdited={onFieldEdited}
viewMode={viewMode}
onDataViewCreated={stateContainer.actions.onDataViewCreated} onDataViewCreated={stateContainer.actions.onDataViewCreated}
availableFields$={stateContainer.dataState.data$.availableFields$} 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 { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container'; 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 * 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 { resetExistingFieldsCache } from '@kbn/unified-field-list/src/hooks/use_existing_fields';
import { createDiscoverServicesMock } from '../../../../__mocks__/services'; import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import type { AggregateQuery, Query } from '@kbn/es-query'; import type { AggregateQuery, Query } from '@kbn/es-query';
import { buildDataTableRecord } from '../../../../utils/build_data_record'; import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { type DataTableRecord } from '../../../../types'; 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', () => ({ jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({ loadFieldStats: jest.fn().mockResolvedValue({
@ -89,11 +115,6 @@ function createMockServices() {
}), }),
}, },
docLinks: { links: { discover: { fieldTypeHelp: '' } } }, docLinks: { links: { discover: { fieldTypeHelp: '' } } },
dataViewEditor: {
userPermissions: {
editDataView: jest.fn(() => true),
},
},
} as unknown as DiscoverServices; } as unknown as DiscoverServices;
return mockServices; return mockServices;
} }
@ -146,9 +167,7 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
selectedDataView: dataView, selectedDataView: dataView,
trackUiMetric: jest.fn(), trackUiMetric: jest.fn(),
onFieldEdited: jest.fn(), onFieldEdited: jest.fn(),
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
onDataViewCreated: jest.fn(), onDataViewCreated: jest.fn(),
useNewFieldsApi: true,
}; };
} }
@ -167,6 +186,7 @@ async function mountComponent(
services?: DiscoverServices services?: DiscoverServices
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> { ): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>; let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
const appState = getAppStateContainer(appStateParams);
const mockedServices = services ?? createMockServices(); const mockedServices = services ?? createMockServices();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () => mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () =>
props.selectedDataView props.selectedDataView
@ -176,11 +196,12 @@ async function mountComponent(
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => { mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
return [props.selectedDataView].find((d) => d!.id === id); return [props.selectedDataView].find((d) => d!.id === id);
}); });
mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState());
await act(async () => { await act(async () => {
comp = await mountWithIntl( comp = await mountWithIntl(
<KibanaContextProvider services={mockedServices}> <KibanaContextProvider services={mockedServices}>
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}> <DiscoverAppStateProvider value={appState}>
<DiscoverSidebarResponsive {...props} /> <DiscoverSidebarResponsive {...props} />
</DiscoverAppStateProvider> </DiscoverAppStateProvider>
</KibanaContextProvider> </KibanaContextProvider>
@ -204,6 +225,7 @@ describe('discover responsive sidebar', function () {
existingFieldNames: Object.keys(mockfieldCounts), existingFieldNames: Object.keys(mockfieldCounts),
})); }));
props = getCompProps(); props = getCompProps();
mockUseCustomizations = false;
}); });
afterEach(() => { 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( expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists() findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
@ -477,32 +508,44 @@ describe('discover responsive sidebar', function () {
result: getDataTableRecords(stubLogstashDataView), result: getDataTableRecords(stubLogstashDataView),
textBasedQueryColumns: [ textBasedQueryColumns: [
{ id: '1', name: 'extension', meta: { type: 'text' } }, { id: '1', name: 'extension', meta: { type: 'text' } },
{ id: '1', name: 'bytes', meta: { type: 'number' } }, { id: '2', name: 'bytes', meta: { type: 'number' } },
{ id: '1', name: '@timestamp', meta: { type: 'date' } }, { id: '3', name: '@timestamp', meta: { type: 'date' } },
], ],
}) as DataDocuments$, }) as DataDocuments$,
}; };
const compInViewerMode = await mountComponent(propsWithTextBasedMode, { const compInTextBasedMode = await mountComponent(propsWithTextBasedMode, {
query: { sql: 'SELECT * FROM `index`' }, 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( const popularFieldsCount = findTestSubject(
compInViewerMode, compInTextBasedMode,
'fieldListGroupedPopularFields-count' 'fieldListGroupedPopularFields-count'
); );
const selectedFieldsCount = findTestSubject( const selectedFieldsCount = findTestSubject(
compInViewerMode, compInTextBasedMode,
'fieldListGroupedSelectedFields-count' 'fieldListGroupedSelectedFields-count'
); );
const availableFieldsCount = findTestSubject( const availableFieldsCount = findTestSubject(
compInViewerMode, compInTextBasedMode,
'fieldListGroupedAvailableFields-count' 'fieldListGroupedAvailableFields-count'
); );
const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count'); const emptyFieldsCount = findTestSubject(
const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count'); compInTextBasedMode,
'fieldListGroupedEmptyFields-count'
);
const metaFieldsCount = findTestSubject(
compInTextBasedMode,
'fieldListGroupedMetaFields-count'
);
const unmappedFieldsCount = findTestSubject( const unmappedFieldsCount = findTestSubject(
compInViewerMode, compInTextBasedMode,
'fieldListGroupedUnmappedFields-count' 'fieldListGroupedUnmappedFields-count'
); );
@ -515,7 +558,7 @@ describe('discover responsive sidebar', function () {
expect(mockCalcFieldCounts.mock.calls.length).toBe(0); 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.' '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 () => { it('should not show "Add a field" button in viewer mode', async () => {
const services = createMockServices(); const services = createMockServices();
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false); services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
const compInViewerMode = await mountComponent(props, {}, services); const compInViewerMode = await mountComponent(props, {}, services);
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled(); expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
expect(findTestSubject(compInViewerMode, 'dataView-add-field_btn').length).toBe(0); 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 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 { UiCounterMetricType } from '@kbn/analytics';
import { import { i18n } from '@kbn/i18n';
EuiBadge,
EuiButton,
EuiFlyout,
EuiFlyoutHeader,
EuiHideFor,
EuiIcon,
EuiLink,
EuiPortal,
EuiShowFor,
EuiTitle,
} from '@elastic/eui';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public'; import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import { useExistingFieldsFetcher, useQuerySubscriber } from '@kbn/unified-field-list'; import { DataViewPicker } from '@kbn/unified-search-plugin/public';
import { VIEW_MODE } from '../../../../../common/constants'; 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 { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { DiscoverSidebar } from './discover_sidebar';
import { import {
AvailableFields$, AvailableFields$,
DataDocuments$, DataDocuments$,
@ -35,22 +27,58 @@ import {
import { calcFieldCounts } from '../../utils/calc_field_counts'; import { calcFieldCounts } from '../../utils/calc_field_counts';
import { FetchStatus } from '../../../types'; import { FetchStatus } from '../../../types';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour'; import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
import { getRawRecordType } from '../../utils/get_raw_record_type'; import { getUiActions } from '../../../../kibana_services';
import { useAppStateSelector } from '../../services/discover_app_state_container';
import { import {
discoverSidebarReducer, discoverSidebarReducer,
getInitialState, getInitialState,
DiscoverSidebarReducerActionType, DiscoverSidebarReducerActionType,
DiscoverSidebarReducerStatus, DiscoverSidebarReducerStatus,
} from './lib/sidebar_reducer'; } from './lib/sidebar_reducer';
import { useDiscoverCustomization } from '../../../../customizations';
const EMPTY_FIELD_COUNTS = {}; 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 { 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 * the selected columns displayed in the doc table in discover
*/ */
@ -90,10 +118,6 @@ export interface DiscoverSidebarResponsiveProps {
* @param eventName * @param eventName
*/ */
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void; trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
/**
* Read from the Fields API
*/
useNewFieldsApi: boolean;
/** /**
* callback to execute on edit runtime field * callback to execute on edit runtime field
*/ */
@ -102,14 +126,14 @@ export interface DiscoverSidebarResponsiveProps {
* callback to execute on create dataview * callback to execute on create dataview
*/ */
onDataViewCreated: (dataView: DataView) => void; onDataViewCreated: (dataView: DataView) => void;
/**
* Discover view mode
*/
viewMode: VIEW_MODE;
/** /**
* list of available fields fetched from ES * list of available fields fetched from ES
*/ */
availableFields$: AvailableFields$; availableFields$: AvailableFields$;
/**
* For customization and testing purposes
*/
fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant'];
} }
/** /**
@ -119,12 +143,18 @@ export interface DiscoverSidebarResponsiveProps {
*/ */
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) { export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const services = useDiscoverServices(); const services = useDiscoverServices();
const { data, dataViews, core } = services; const {
const isPlainRecord = useAppStateSelector( fieldListVariant,
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN selectedDataView,
); columns,
const { selectedDataView, onFieldEdited, onDataViewCreated } = props; trackUiMetric,
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); onAddFilter,
onFieldEdited,
onDataViewCreated,
onChangeDataView,
onAddField,
onRemoveField,
} = props;
const [sidebarState, dispatchSidebarStateAction] = useReducer( const [sidebarState, dispatchSidebarStateAction] = useReducer(
discoverSidebarReducer, discoverSidebarReducer,
selectedDataView, selectedDataView,
@ -132,6 +162,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
); );
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView); const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL; const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
useState<UnifiedFieldListSidebarContainerApi | null>(null);
useEffect(() => { useEffect(() => {
const subscription = props.documents$.subscribe((documentState) => { const subscription = props.documents$.subscribe((documentState) => {
@ -196,38 +228,50 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
} }
}, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]); }, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]);
const querySubscriberResult = useQuerySubscriber({ data }); const refetchFieldsExistenceInfo =
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length); unifiedFieldListSidebarContainerApi?.refetchFieldsExistenceInfo;
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({ const scheduleFieldsExistenceInfoFetchRef = useRef<boolean>(false);
disableAutoFetching: true,
dataViews: !isPlainRecord && sidebarState.dataView ? [sidebarState.dataView] : [],
query: querySubscriberResult.query,
filters: querySubscriberResult.filters,
fromDate: querySubscriberResult.fromDate,
toDate: querySubscriberResult.toDate,
services: {
data,
dataViews,
core,
},
});
// Refetch fields existence info only after the fetch completes
useEffect(() => { useEffect(() => {
if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) { scheduleFieldsExistenceInfoFetchRef.current = false;
refetchFieldsExistenceInfo();
} if (sidebarState.status !== DiscoverSidebarReducerStatus.COMPLETED) {
// refetching only if status changes return;
// eslint-disable-next-line react-hooks/exhaustive-deps }
}, [sidebarState.status]);
// 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>(); const closeDataViewEditor = useRef<() => void | undefined>();
useEffect(() => { useEffect(() => {
const cleanup = () => { const cleanup = () => {
if (closeFieldEditor?.current) {
closeFieldEditor?.current();
}
if (closeDataViewEditor?.current) { if (closeDataViewEditor?.current) {
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) => { const setDataViewEditorRef = useCallback((ref: () => void | undefined) => {
closeDataViewEditor.current = ref; closeDataViewEditor.current = ref;
}, []); }, []);
const closeFlyout = useCallback(() => { const { dataViewEditor } = services;
setIsFlyoutVisible(false);
}, []);
const { dataViewFieldEditor, dataViewEditor } = services;
const { availableFields$ } = props; const { availableFields$ } = props;
const canEditDataView =
Boolean(dataViewEditor?.userPermissions.editDataView()) || !selectedDataView?.isPersisted();
useEffect(() => { useEffect(() => {
// For an external embeddable like the Field stats // For an external embeddable like the Field stats
// it is useful to know what fields are populated in the docs fetched // 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$]); }, [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 canEditDataView
? (fieldName?: string) => { ? () => {
const ref = dataViewFieldEditor.openEditor({ const ref = dataViewEditor.openEditor({
ctx: { onSave: async (dataView) => {
dataView: selectedDataView, onDataViewCreated(dataView);
},
fieldName,
onSave: async () => {
await onFieldEdited();
}, },
}); });
if (setFieldEditorRef) { if (setDataViewEditorRef) {
setFieldEditorRef(ref); setDataViewEditorRef(ref);
}
if (closeFlyout) {
closeFlyout();
} }
closeFieldListFlyout?.();
} }
: undefined, : undefined,
[ [canEditDataView, dataViewEditor, setDataViewEditorRef, onDataViewCreated, closeFieldListFlyout]
isPlainRecord,
canEditDataView,
dataViewFieldEditor,
selectedDataView,
setFieldEditorRef,
closeFlyout,
onFieldEdited,
]
); );
const createNewDataView = useCallback(() => { const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
const ref = dataViewEditor.openEditor({ () => ({
onSave: async (dataView) => { ...services,
onDataViewCreated(dataView); uiActions: getUiActions(),
}, }),
}); [services]
if (setDataViewEditorRef) { );
setDataViewEditorRef(ref);
} const searchBarCustomization = useDiscoverCustomization('search_bar');
if (closeFlyout) { const CustomDataViewPicker = searchBarCustomization?.CustomDataViewPicker;
closeFlyout();
} const createField = unifiedFieldListSidebarContainerApi?.createField;
}, [dataViewEditor, setDataViewEditorRef, closeFlyout, onDataViewCreated]); 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) { if (!selectedDataView) {
return null; return null;
} }
return ( return (
<> <UnifiedFieldListSidebarContainer
{!props.isClosed && ( ref={initializeUnifiedFieldListSidebarContainerApi}
<EuiHideFor sizes={['xs', 's']}> variant={fieldListVariant}
<DiscoverSidebar getCreationOptions={getCreationOptions}
{...props} isSidebarCollapsed={props.isClosed}
isProcessing={isProcessing} services={fieldListSidebarServices}
onFieldEdited={onFieldEdited} dataView={selectedDataView}
allFields={sidebarState.allFields} trackUiMetric={trackUiMetric}
editField={editField} allFields={sidebarState.allFields}
createNewDataView={createNewDataView} showFieldList={showFieldList}
showFieldList={showFieldList} workspaceSelectedFieldNames={columns}
isAffectedByGlobalFilter={isAffectedByGlobalFilter} onAddFieldToWorkspace={onAddFieldToWorkspace}
/> onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
</EuiHideFor> onAddFilter={onAddFilter}
)} onFieldEdited={onFieldEdited}
<EuiShowFor sizes={['xs', 's']}> prependInFlyout={prependDataViewPickerForMobile}
<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>
</>
); );
} }

View file

@ -6,5 +6,4 @@
* Side Public License, v 1. * Side Public License, v 1.
*/ */
export { DiscoverSidebar } from './discover_sidebar';
export { DiscoverSidebarResponsive } from './discover_sidebar_responsive'; 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 () => { it('should return examples for non-aggregatable or geo fields', async () => {
await PageObjects.unifiedFieldList.clickFieldListItem('extension'); await PageObjects.unifiedFieldList.clickFieldListItem('geo.coordinates');
expect(await PageObjects.unifiedFieldList.getFieldStatsViewType()).to.be('exampleValues'); expect(await PageObjects.unifiedFieldList.getFieldStatsViewType()).to.be('exampleValues');
expect(await PageObjects.unifiedFieldList.getFieldStatsDocsCount()).to.be(100); expect(await PageObjects.unifiedFieldList.getFieldStatsDocsCount()).to.be(100);
// actual hits might vary // 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.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.",
"discover.embeddable.search.displayName": "rechercher", "discover.embeddable.search.displayName": "rechercher",
"discover.errorCalloutShowErrorMessage": "Afficher les détails", "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.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.",
"discover.fieldChooser.discoverField.actions": "Actions", "discover.fieldChooser.discoverField.actions": "Actions",
"discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne", "discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne",
"discover.fieldChooser.discoverField.multiField": "champ multiple", "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.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs.",
"discover.fieldChooser.discoverField.name": "Champ", "discover.fieldChooser.discoverField.name": "Champ",
"discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau", "discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau",
"discover.fieldChooser.discoverField.value": "Valeur", "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.goToDiscoverButtonText": "Aller à Discover",
"discover.grid.closePopover": "Fermer la fenêtre contextuelle", "discover.grid.closePopover": "Fermer la fenêtre contextuelle",
"discover.grid.copyCellValueButton": "Copier la valeur", "discover.grid.copyCellValueButton": "Copier la valeur",

View file

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

View file

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