mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
d8c8b7b0f0
commit
ea53763028
39 changed files with 1799 additions and 1528 deletions
|
@ -17,7 +17,8 @@
|
|||
"dataViews",
|
||||
"dataViewFieldEditor",
|
||||
"charts",
|
||||
"fieldFormats"
|
||||
"fieldFormats",
|
||||
"uiActions"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { CoreThemeProvider } from '@kbn/core-theme-browser-internal';
|
||||
import type { AppMountParameters, CoreStart } from '@kbn/core/public';
|
||||
import { AppPluginStartDependencies } from './types';
|
||||
import { UnifiedFieldListExampleApp } from './example_app';
|
||||
|
@ -16,17 +17,18 @@ import { UnifiedFieldListExampleApp } from './example_app';
|
|||
export const renderApp = (
|
||||
core: CoreStart,
|
||||
deps: AppPluginStartDependencies,
|
||||
{ element }: AppMountParameters
|
||||
{ element, theme$ }: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(
|
||||
<I18nProvider>
|
||||
<UnifiedFieldListExampleApp
|
||||
services={{
|
||||
core,
|
||||
uiSettings: core.uiSettings,
|
||||
...deps,
|
||||
}}
|
||||
/>
|
||||
<CoreThemeProvider theme$={theme$}>
|
||||
<UnifiedFieldListExampleApp
|
||||
services={{
|
||||
core,
|
||||
...deps,
|
||||
}}
|
||||
/>
|
||||
</CoreThemeProvider>
|
||||
</I18nProvider>,
|
||||
element
|
||||
);
|
||||
|
|
|
@ -7,13 +7,11 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPage,
|
||||
EuiPageBody,
|
||||
EuiPageSidebar,
|
||||
EuiTitle,
|
||||
EuiEmptyPrompt,
|
||||
EuiLoadingLogo,
|
||||
|
@ -38,7 +36,7 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
|
|||
const [dataView, setDataView] = useState<DataView | null>();
|
||||
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
|
||||
|
||||
const onAddFieldToWorkplace = useCallback(
|
||||
const onAddFieldToWorkspace = useCallback(
|
||||
(field: DataViewField) => {
|
||||
setSelectedFieldNames((names) => [...names, field.name]);
|
||||
},
|
||||
|
@ -124,20 +122,13 @@ export const UnifiedFieldListExampleApp: React.FC<UnifiedFieldListExampleAppProp
|
|||
<RootDragDropProvider>
|
||||
<EuiFlexGroup direction="row" alignItems="stretch">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPageSidebar
|
||||
css={css`
|
||||
flex: 1;
|
||||
width: 320px;
|
||||
`}
|
||||
>
|
||||
<FieldListSidebar
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
selectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkplace={onAddFieldToWorkplace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
/>
|
||||
</EuiPageSidebar>
|
||||
<FieldListSidebar
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
selectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<ExampleDropZone onDropField={onDropFieldToWorkplace} />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -14,114 +14,86 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react';
|
||||
import type { DataView } from '@kbn/data-views-plugin/public';
|
||||
import { generateFilters } from '@kbn/data-plugin/public';
|
||||
import { ChildDragDropProvider, DragContext } from '@kbn/dom-drag-drop';
|
||||
import {
|
||||
FieldList,
|
||||
FieldListFilters,
|
||||
FieldListGrouped,
|
||||
FieldListGroupedProps,
|
||||
FieldsGroupNames,
|
||||
useExistingFieldsFetcher,
|
||||
useGroupedFields,
|
||||
useQuerySubscriber,
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
type UnifiedFieldListSidebarContainerApi,
|
||||
type AddFieldFilterHandler,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { FieldListItem, FieldListItemProps } from './field_list_item';
|
||||
import { type CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import { PLUGIN_ID } from '../common';
|
||||
import { type AppPluginStartDependencies } from './types';
|
||||
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: PLUGIN_ID,
|
||||
localStorageKeyPrefix: 'examples',
|
||||
timeRangeUpdatesType: 'timefilter',
|
||||
disablePopularFields: true,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FieldListSidebarProps {
|
||||
dataView: DataView;
|
||||
selectedFieldNames: string[];
|
||||
services: FieldListItemProps['services'];
|
||||
onAddFieldToWorkplace: FieldListItemProps['onAddFieldToWorkspace'];
|
||||
onRemoveFieldFromWorkspace: FieldListItemProps['onRemoveFieldFromWorkspace'];
|
||||
services: AppPluginStartDependencies & {
|
||||
core: CoreStart;
|
||||
};
|
||||
onAddFieldToWorkspace: UnifiedFieldListSidebarContainerProps['onAddFieldToWorkspace'];
|
||||
onRemoveFieldFromWorkspace: UnifiedFieldListSidebarContainerProps['onRemoveFieldFromWorkspace'];
|
||||
}
|
||||
|
||||
export const FieldListSidebar: React.FC<FieldListSidebarProps> = ({
|
||||
dataView,
|
||||
selectedFieldNames,
|
||||
services,
|
||||
onAddFieldToWorkplace,
|
||||
onAddFieldToWorkspace,
|
||||
onRemoveFieldFromWorkspace,
|
||||
}) => {
|
||||
const dragDropContext = useContext(DragContext);
|
||||
const allFields = dataView.fields;
|
||||
const activeDataViews = useMemo(() => [dataView], [dataView]);
|
||||
const querySubscriberResult = useQuerySubscriber({
|
||||
data: services.data,
|
||||
listenToSearchSessionUpdates: false, // this example app does not use search sessions
|
||||
});
|
||||
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
|
||||
const filterManager = services.data?.query?.filterManager;
|
||||
|
||||
const onSelectedFieldFilter = useCallback(
|
||||
(field: DataViewField) => {
|
||||
return selectedFieldNames.includes(field.name);
|
||||
},
|
||||
[selectedFieldNames]
|
||||
const onAddFilter: AddFieldFilterHandler | undefined = useMemo(
|
||||
() =>
|
||||
filterManager && dataView
|
||||
? (clickedField, values, operation) => {
|
||||
const newFilters = generateFilters(
|
||||
filterManager,
|
||||
clickedField,
|
||||
values,
|
||||
operation,
|
||||
dataView
|
||||
);
|
||||
filterManager.addFilters(newFilters);
|
||||
}
|
||||
: undefined,
|
||||
[dataView, filterManager]
|
||||
);
|
||||
|
||||
const { refetchFieldsExistenceInfo, isProcessing } = useExistingFieldsFetcher({
|
||||
dataViews: activeDataViews, // if you need field existence info for more than one data view, you can specify it here
|
||||
query: querySubscriberResult.query,
|
||||
filters: querySubscriberResult.filters,
|
||||
fromDate: querySubscriberResult.fromDate,
|
||||
toDate: querySubscriberResult.toDate,
|
||||
services,
|
||||
});
|
||||
|
||||
const { fieldListFiltersProps, fieldListGroupedProps } = useGroupedFields({
|
||||
dataViewId: dataView.id ?? null,
|
||||
allFields,
|
||||
services,
|
||||
isAffectedByGlobalFilter: Boolean(querySubscriberResult.filters?.length),
|
||||
onSupportedFieldFilter,
|
||||
onSelectedFieldFilter,
|
||||
});
|
||||
|
||||
const onRefreshFields = useCallback(() => {
|
||||
refetchFieldsExistenceInfo();
|
||||
}, [refetchFieldsExistenceInfo]);
|
||||
|
||||
const renderFieldItem: FieldListGroupedProps<DataViewField>['renderFieldItem'] = useCallback(
|
||||
(params) => (
|
||||
<FieldListItem
|
||||
dataView={dataView}
|
||||
services={services}
|
||||
isSelected={
|
||||
params.groupName === FieldsGroupNames.SelectedFields ||
|
||||
selectedFieldNames.includes(params.field.name)
|
||||
}
|
||||
onRefreshFields={onRefreshFields}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkplace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
{...params}
|
||||
/>
|
||||
),
|
||||
[
|
||||
dataView,
|
||||
services,
|
||||
onRefreshFields,
|
||||
selectedFieldNames,
|
||||
onAddFieldToWorkplace,
|
||||
onRemoveFieldFromWorkspace,
|
||||
]
|
||||
);
|
||||
const onFieldEdited = useCallback(async () => {
|
||||
unifiedFieldListContainerRef.current?.refetchFieldsExistenceInfo();
|
||||
}, [unifiedFieldListContainerRef]);
|
||||
|
||||
return (
|
||||
<ChildDragDropProvider {...dragDropContext}>
|
||||
<FieldList
|
||||
isProcessing={isProcessing}
|
||||
prepend={<FieldListFilters {...fieldListFiltersProps} />}
|
||||
>
|
||||
<FieldListGrouped
|
||||
{...fieldListGroupedProps}
|
||||
renderFieldItem={renderFieldItem}
|
||||
localStorageKeyPrefix="examples"
|
||||
/>
|
||||
</FieldList>
|
||||
<UnifiedFieldListSidebarContainer
|
||||
ref={unifiedFieldListContainerRef}
|
||||
variant="responsive"
|
||||
getCreationOptions={getCreationOptions}
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
allFields={dataView.fields}
|
||||
workspaceSelectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
onAddFilter={onAddFilter}
|
||||
onFieldEdited={onFieldEdited}
|
||||
/>
|
||||
</ChildDragDropProvider>
|
||||
);
|
||||
};
|
||||
|
||||
function onSupportedFieldFilter(field: DataViewField): boolean {
|
||||
return field.name !== '_source';
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
|
|||
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
|
||||
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
|
||||
import type { DataViewFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
|
||||
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface UnifiedFieldListExamplesPluginSetup {}
|
||||
|
@ -32,4 +33,5 @@ export interface AppPluginStartDependencies {
|
|||
unifiedSearch: UnifiedSearchPublicPluginStart;
|
||||
charts: ChartsPluginStart;
|
||||
fieldFormats: FieldFormatsStart;
|
||||
uiActions: UiActionsStart;
|
||||
}
|
||||
|
|
|
@ -28,5 +28,7 @@
|
|||
"@kbn/field-formats-plugin",
|
||||
"@kbn/data-view-field-editor-plugin",
|
||||
"@kbn/unified-field-list",
|
||||
"@kbn/core-theme-browser-internal",
|
||||
"@kbn/ui-actions-plugin",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/dom-drag-drop",
|
||||
"owner": [
|
||||
"@elastic/kibana-visualizations",
|
||||
|
|
|
@ -2,9 +2,60 @@
|
|||
|
||||
This Kibana package contains components and services for field list UI (as in fields sidebar on Discover and Lens pages).
|
||||
|
||||
## UnifiedFieldListSidebarContainer - building block
|
||||
|
||||
An example of its usage can be found in Kibana example plugin [examples/unified_field_list_examples](/examples/unified_field_list_examples).
|
||||
|
||||
Configure the field list:
|
||||
```
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: PLUGIN_ID,
|
||||
localStorageKeyPrefix: 'examples',
|
||||
timeRangeUpdatesType: 'timefilter',
|
||||
disablePopularFields: true,
|
||||
... // more customization option are available
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Define a ref for accessing API if necessary:
|
||||
```
|
||||
const unifiedFieldListContainerRef = useRef<UnifiedFieldListSidebarContainerApi>(null);
|
||||
```
|
||||
|
||||
where `unifiedFieldListContainerRef.current` provides the following API:
|
||||
|
||||
```
|
||||
refetchFieldsExistenceInfo: ExistingFieldsFetcher['refetchFieldsExistenceInfo'];
|
||||
closeFieldListFlyout: () => void;
|
||||
// no user permission or missing dataViewFieldEditor service will result in `undefined` API methods
|
||||
createField: undefined | (() => void);
|
||||
editField: undefined | ((fieldName: string) => void);
|
||||
deleteField: undefined | ((fieldName: string) => void);
|
||||
```
|
||||
|
||||
Include the building block into your application:
|
||||
```
|
||||
<UnifiedFieldListSidebarContainer
|
||||
ref={unifiedFieldListContainerRef}
|
||||
// `responsive` is to show the list for desktop view and a button which triggers a flyout with the list for mobile view
|
||||
variant="responsive" // can be also `list-always` and `button-and-flyout-always`
|
||||
getCreationOptions={getCreationOptions}
|
||||
services={services}
|
||||
dataView={dataView}
|
||||
allFields={dataView.fields}
|
||||
workspaceSelectedFieldNames={selectedFieldNames}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
onAddFilter={onAddFilter}
|
||||
onFieldEdited={onFieldEdited}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Field Stats and Field Popover Components
|
||||
## Field Stats and Field Popover Components - can be also used as a building block
|
||||
|
||||
* `<FieldStats .../>` - loads and renders stats (Top values, Distribution) for a data view field.
|
||||
|
||||
|
@ -53,7 +104,7 @@ These components can be combined and customized as the following:
|
|||
/>
|
||||
```
|
||||
|
||||
## Field List components
|
||||
## Field List subcomponents (for low level customization, otherwise consider using UnifiedFieldListSidebarContainer)
|
||||
|
||||
* `<FieldList .../>` - a top-level component to render field filters and field list sections.
|
||||
|
||||
|
@ -139,12 +190,6 @@ const { hasFieldData } = useExistingFieldsReader();
|
|||
const hasData = hasFieldData(currentDataViewId, fieldName) // returns a boolean
|
||||
```
|
||||
|
||||
## Server APIs
|
||||
|
||||
* `/internal/unified_field_list/field_stats` - returns the loaded field stats (except for Ad-hoc data views)
|
||||
|
||||
* `/internal/unified_field_list/existing_fields/{dataViewId}` - returns the loaded existing fields (except for Ad-hoc data views)
|
||||
|
||||
## Development
|
||||
|
||||
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment.
|
||||
|
|
42
packages/kbn-unified-field-list/__mocks__/services.mock.ts
Normal file
42
packages/kbn-unified-field-list/__mocks__/services.mock.ts
Normal 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;
|
||||
};
|
|
@ -52,6 +52,7 @@ export type {
|
|||
FieldListItem,
|
||||
GetCustomFieldType,
|
||||
RenderFieldItemParams,
|
||||
SearchMode,
|
||||
} from './src/types';
|
||||
export { ExistenceFetchStatus, FieldsGroupNames } from './src/types';
|
||||
|
||||
|
@ -80,6 +81,7 @@ export {
|
|||
export {
|
||||
useQuerySubscriber,
|
||||
hasQuerySubscriberData,
|
||||
getSearchMode,
|
||||
type QuerySubscriberResult,
|
||||
type QuerySubscriberParams,
|
||||
} from './src/hooks/use_query_subscriber';
|
||||
|
@ -91,3 +93,9 @@ export {
|
|||
getFieldType,
|
||||
getFieldIconType,
|
||||
} from './src/utils/field_types';
|
||||
|
||||
export {
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerApi,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
} from './src/containers/unified_field_list_sidebar';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -11,16 +11,14 @@ import { EuiButtonIcon, EuiPopover, EuiProgress } from '@elastic/eui';
|
|||
import React from 'react';
|
||||
import { findTestSubject } from '@elastic/eui/lib/test';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import { DiscoverField, DiscoverFieldProps } from './discover_field';
|
||||
import { DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { stubDataView } from '@kbn/data-views-plugin/common/data_view.stub';
|
||||
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import { FieldItemButton } from '@kbn/unified-field-list';
|
||||
import { getServicesMock } from '../../../__mocks__/services.mock';
|
||||
import { UnifiedFieldListItem, UnifiedFieldListItemProps } from './field_list_item';
|
||||
import { FieldItemButton } from '../../components/field_item_button';
|
||||
import { createStateService } from '../services/state_service';
|
||||
|
||||
jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
|
||||
jest.mock('../../services/field_stats', () => ({
|
||||
loadFieldStats: jest.fn().mockResolvedValue({
|
||||
totalDocuments: 1624,
|
||||
sampledDocuments: 1624,
|
||||
|
@ -40,22 +38,14 @@ jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../kibana_services', () => ({
|
||||
getUiActions: jest.fn(() => {
|
||||
return {
|
||||
getTriggerCompatibleActions: jest.fn(() => []),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
async function getComponent({
|
||||
selected = false,
|
||||
field,
|
||||
onAddFilterExists = true,
|
||||
canFilter = true,
|
||||
}: {
|
||||
selected?: boolean;
|
||||
field?: DataViewField;
|
||||
onAddFilterExists?: boolean;
|
||||
canFilter?: boolean;
|
||||
}) {
|
||||
const finalField =
|
||||
field ??
|
||||
|
@ -72,62 +62,45 @@ async function getComponent({
|
|||
const dataView = stubDataView;
|
||||
dataView.toSpec = () => ({});
|
||||
|
||||
const props: DiscoverFieldProps = {
|
||||
const stateService = createStateService({
|
||||
options: {
|
||||
originatingApp: 'test',
|
||||
},
|
||||
});
|
||||
|
||||
const props: UnifiedFieldListItemProps = {
|
||||
services: getServicesMock(),
|
||||
stateService,
|
||||
searchMode: 'documents',
|
||||
dataView: stubDataView,
|
||||
field: finalField,
|
||||
...(onAddFilterExists && { onAddFilter: jest.fn() }),
|
||||
onAddField: jest.fn(),
|
||||
...(canFilter && { onAddFilter: jest.fn() }),
|
||||
onAddFieldToWorkspace: jest.fn(),
|
||||
onRemoveFieldFromWorkspace: jest.fn(),
|
||||
onEditField: jest.fn(),
|
||||
onRemoveField: jest.fn(),
|
||||
isSelected: selected,
|
||||
isEmpty: false,
|
||||
groupIndex: 1,
|
||||
itemIndex: 0,
|
||||
contextualFields: [],
|
||||
workspaceSelectedFieldNames: [],
|
||||
};
|
||||
const services = {
|
||||
...createDiscoverServicesMock(),
|
||||
capabilities: {
|
||||
visualize: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: (key: string) => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
const appStateContainer = getDiscoverStateMock({ isTimeBased: true }).appState;
|
||||
appStateContainer.set({
|
||||
query: { query: '', language: 'lucene' },
|
||||
filters: [],
|
||||
});
|
||||
const comp = await mountWithIntl(
|
||||
<KibanaContextProvider services={services}>
|
||||
<DiscoverAppStateProvider value={appStateContainer}>
|
||||
<DiscoverField {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
const comp = await mountWithIntl(<UnifiedFieldListItem {...props} />);
|
||||
// wait for lazy modules
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await comp.update();
|
||||
return { comp, props };
|
||||
}
|
||||
|
||||
describe('discover sidebar field', function () {
|
||||
describe('UnifiedFieldListItem', function () {
|
||||
it('should allow selecting fields', async function () {
|
||||
const { comp, props } = await getComponent({});
|
||||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
expect(props.onAddField).toHaveBeenCalledWith('bytes');
|
||||
expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(props.field);
|
||||
});
|
||||
it('should allow deselecting fields', async function () {
|
||||
const { comp, props } = await getComponent({ selected: true });
|
||||
findTestSubject(comp, 'fieldToggle-bytes').simulate('click');
|
||||
expect(props.onRemoveField).toHaveBeenCalledWith('bytes');
|
||||
expect(props.onRemoveFieldFromWorkspace).toHaveBeenCalledWith(props.field);
|
||||
});
|
||||
it('displays warning for conflicting fields', async function () {
|
||||
const field = new DataViewField({
|
||||
|
@ -157,7 +130,7 @@ describe('discover sidebar field', function () {
|
|||
const { comp } = await getComponent({
|
||||
selected: true,
|
||||
field,
|
||||
onAddFilterExists: false,
|
||||
canFilter: false,
|
||||
});
|
||||
|
||||
expect(comp.find(FieldItemButton).prop('onClick')).toBeUndefined();
|
||||
|
@ -171,7 +144,7 @@ describe('discover sidebar field', function () {
|
|||
searchable: true,
|
||||
});
|
||||
|
||||
const { comp } = await getComponent({ field, onAddFilterExists: true });
|
||||
const { comp } = await getComponent({ field, canFilter: true });
|
||||
|
||||
await act(async () => {
|
||||
const fieldItem = findTestSubject(comp, 'field-machine.os.raw-showDetails');
|
||||
|
@ -186,13 +159,13 @@ describe('discover sidebar field', function () {
|
|||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await comp.update();
|
||||
|
||||
expect(findTestSubject(comp, 'dscFieldStats-title').text()).toBe('Top values');
|
||||
expect(findTestSubject(comp, 'dscFieldStats-topValues-bucket')).toHaveLength(2);
|
||||
expect(
|
||||
findTestSubject(comp, 'dscFieldStats-topValues-formattedFieldValue').first().text()
|
||||
).toBe('osx');
|
||||
expect(findTestSubject(comp, 'fieldStats-title').text()).toBe('Top values');
|
||||
expect(findTestSubject(comp, 'fieldStats-topValues-bucket')).toHaveLength(2);
|
||||
expect(findTestSubject(comp, 'fieldStats-topValues-formattedFieldValue').first().text()).toBe(
|
||||
'osx'
|
||||
);
|
||||
expect(comp.find(EuiProgress)).toHaveLength(2);
|
||||
expect(findTestSubject(comp, 'dscFieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
|
||||
expect(findTestSubject(comp, 'fieldStats-topValues').find(EuiButtonIcon)).toHaveLength(4);
|
||||
});
|
||||
it('should include popover actions', async function () {
|
||||
const field = new DataViewField({
|
||||
|
@ -203,7 +176,7 @@ describe('discover sidebar field', function () {
|
|||
searchable: true,
|
||||
});
|
||||
|
||||
const { comp, props } = await getComponent({ field, onAddFilterExists: true });
|
||||
const { comp, props } = await getComponent({ field, canFilter: true });
|
||||
|
||||
await act(async () => {
|
||||
const fieldItem = findTestSubject(comp, 'field-extension.keyword-showDetails');
|
||||
|
@ -218,15 +191,13 @@ describe('discover sidebar field', function () {
|
|||
comp.find('[data-test-subj="fieldPopoverHeader_addField-extension.keyword"]').exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
comp
|
||||
.find('[data-test-subj="discoverFieldListPanelAddExistFilter-extension.keyword"]')
|
||||
.exists()
|
||||
comp.find('[data-test-subj="fieldPopoverHeader_addExistsFilter-extension.keyword"]').exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
comp.find('[data-test-subj="discoverFieldListPanelEdit-extension.keyword"]').exists()
|
||||
comp.find('[data-test-subj="fieldPopoverHeader_editField-extension.keyword"]').exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
comp.find('[data-test-subj="discoverFieldListPanelDelete-extension.keyword"]').exists()
|
||||
comp.find('[data-test-subj="fieldPopoverHeader_deleteField-extension.keyword"]').exists()
|
||||
).toBeFalsy();
|
||||
|
||||
await act(async () => {
|
||||
|
@ -235,7 +206,7 @@ describe('discover sidebar field', function () {
|
|||
await comp.update();
|
||||
});
|
||||
|
||||
expect(props.onAddField).toHaveBeenCalledWith('extension.keyword');
|
||||
expect(props.onAddFieldToWorkspace).toHaveBeenCalledWith(field);
|
||||
|
||||
await comp.update();
|
||||
|
||||
|
@ -253,7 +224,7 @@ describe('discover sidebar field', function () {
|
|||
|
||||
const { comp } = await getComponent({
|
||||
field,
|
||||
onAddFilterExists: true,
|
||||
canFilter: true,
|
||||
selected: true,
|
||||
});
|
||||
|
|
@ -6,41 +6,47 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import './discover_field.scss';
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import { DragDrop } from '@kbn/dom-drag-drop';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { SearchMode } from '../../types';
|
||||
import { FieldItemButton, type FieldItemButtonProps } from '../../components/field_item_button';
|
||||
import {
|
||||
FieldItemButton,
|
||||
type FieldItemButtonProps,
|
||||
FieldPopover,
|
||||
FieldPopoverHeader,
|
||||
FieldPopoverHeaderProps,
|
||||
type FieldPopoverHeaderProps,
|
||||
FieldPopoverFooter,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { DragDrop } from '@kbn/dom-drag-drop';
|
||||
import { DiscoverFieldStats } from './discover_field_stats';
|
||||
import { PLUGIN_ID } from '../../../../../common';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
type FieldPopoverFooterProps,
|
||||
} from '../../components/field_popover';
|
||||
import {
|
||||
UnifiedFieldListItemStats,
|
||||
type UnifiedFieldListItemStatsProps,
|
||||
} from './field_list_item_stats';
|
||||
import type {
|
||||
UnifiedFieldListSidebarContainerStateService,
|
||||
AddFieldFilterHandler,
|
||||
} from '../../types';
|
||||
|
||||
interface GetCommonFieldItemButtonPropsParams {
|
||||
stateService: UnifiedFieldListSidebarContainerStateService;
|
||||
field: DataViewField;
|
||||
isSelected: boolean;
|
||||
toggleDisplay: (field: DataViewField, isSelected?: boolean) => void;
|
||||
}
|
||||
|
||||
function getCommonFieldItemButtonProps({
|
||||
stateService,
|
||||
field,
|
||||
isSelected,
|
||||
toggleDisplay,
|
||||
}: GetCommonFieldItemButtonPropsParams): {
|
||||
field: FieldItemButtonProps<DataViewField>['field'];
|
||||
isSelected: FieldItemButtonProps<DataViewField>['isSelected'];
|
||||
buttonAddFieldToWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
|
||||
buttonRemoveFieldFromWorkspaceProps: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
|
||||
buttonAddFieldToWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonAddFieldToWorkspaceProps'];
|
||||
buttonRemoveFieldFromWorkspaceProps?: FieldItemButtonProps<DataViewField>['buttonRemoveFieldFromWorkspaceProps'];
|
||||
onAddFieldToWorkspace: FieldItemButtonProps<DataViewField>['onAddFieldToWorkspace'];
|
||||
onRemoveFieldFromWorkspace: FieldItemButtonProps<DataViewField>['onRemoveFieldFromWorkspace'];
|
||||
} {
|
||||
|
@ -49,33 +55,27 @@ function getCommonFieldItemButtonProps({
|
|||
return {
|
||||
field,
|
||||
isSelected,
|
||||
buttonAddFieldToWorkspaceProps: {
|
||||
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
|
||||
defaultMessage: 'Add field as column',
|
||||
}),
|
||||
},
|
||||
buttonRemoveFieldFromWorkspaceProps: {
|
||||
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
|
||||
defaultMessage: 'Remove field from table',
|
||||
}),
|
||||
},
|
||||
buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
|
||||
buttonRemoveFieldFromWorkspaceProps:
|
||||
stateService.creationOptions.buttonRemoveFieldFromWorkspaceProps,
|
||||
onAddFieldToWorkspace: handler,
|
||||
onRemoveFieldFromWorkspace: handler,
|
||||
};
|
||||
}
|
||||
|
||||
interface MultiFieldsProps {
|
||||
multiFields: NonNullable<DiscoverFieldProps['multiFields']>;
|
||||
stateService: UnifiedFieldListSidebarContainerStateService;
|
||||
multiFields: NonNullable<UnifiedFieldListItemProps['multiFields']>;
|
||||
toggleDisplay: (field: DataViewField) => void;
|
||||
alwaysShowActionButton: boolean;
|
||||
}
|
||||
|
||||
const MultiFields: React.FC<MultiFieldsProps> = memo(
|
||||
({ multiFields, toggleDisplay, alwaysShowActionButton }) => (
|
||||
({ stateService, multiFields, toggleDisplay, alwaysShowActionButton }) => (
|
||||
<React.Fragment>
|
||||
<EuiTitle size="xxxs">
|
||||
<h5>
|
||||
{i18n.translate('discover.fieldChooser.discoverField.multiFields', {
|
||||
{i18n.translate('unifiedFieldList.fieldListItem.multiFields', {
|
||||
defaultMessage: 'Multi fields',
|
||||
})}
|
||||
</h5>
|
||||
|
@ -85,13 +85,13 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
|
|||
<FieldItemButton
|
||||
key={entry.field.name}
|
||||
size="xs"
|
||||
className="dscSidebarItem dscSidebarItem--multi"
|
||||
flush="both"
|
||||
isEmpty={false}
|
||||
isActive={false}
|
||||
shouldAlwaysShowAction={alwaysShowActionButton}
|
||||
onClick={undefined}
|
||||
{...getCommonFieldItemButtonProps({
|
||||
stateService,
|
||||
field: entry.field,
|
||||
isSelected: entry.isSelected,
|
||||
toggleDisplay,
|
||||
|
@ -102,7 +102,22 @@ const MultiFields: React.FC<MultiFieldsProps> = memo(
|
|||
)
|
||||
);
|
||||
|
||||
export interface DiscoverFieldProps {
|
||||
export interface UnifiedFieldListItemProps {
|
||||
/**
|
||||
* Service for managing the state
|
||||
*/
|
||||
stateService: UnifiedFieldListSidebarContainerStateService;
|
||||
|
||||
/**
|
||||
* Required services
|
||||
*/
|
||||
services: UnifiedFieldListItemStatsProps['services'] & {
|
||||
uiActions?: FieldPopoverFooterProps['uiActions'];
|
||||
};
|
||||
/**
|
||||
* Current search mode
|
||||
*/
|
||||
searchMode: SearchMode | undefined;
|
||||
/**
|
||||
* Determines whether add/remove button is displayed not only when focused
|
||||
*/
|
||||
|
@ -118,16 +133,16 @@ export interface DiscoverFieldProps {
|
|||
/**
|
||||
* Callback to add/select the field
|
||||
*/
|
||||
onAddField: (fieldName: string) => void;
|
||||
/**
|
||||
* Callback to add a filter to filter bar
|
||||
*/
|
||||
onAddFilter?: (field: DataViewField | string, value: unknown, type: '+' | '-') => void;
|
||||
onAddFieldToWorkspace: (field: DataViewField) => void;
|
||||
/**
|
||||
* Callback to remove a field column from the table
|
||||
* @param fieldName
|
||||
*/
|
||||
onRemoveField: (fieldName: string) => void;
|
||||
onRemoveFieldFromWorkspace: (field: DataViewField) => void;
|
||||
/**
|
||||
* Callback to add a filter to filter bar
|
||||
*/
|
||||
onAddFilter?: AddFieldFilterHandler;
|
||||
/**
|
||||
* Determines whether the field is empty
|
||||
*/
|
||||
|
@ -142,49 +157,48 @@ export interface DiscoverFieldProps {
|
|||
* @param eventName
|
||||
*/
|
||||
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
|
||||
/**
|
||||
* Multi fields for the current field
|
||||
*/
|
||||
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
|
||||
|
||||
/**
|
||||
* Callback to edit a field from data view
|
||||
* @param fieldName name of the field to edit
|
||||
*/
|
||||
onEditField?: (fieldName: string) => void;
|
||||
|
||||
onEditField?: (fieldName?: string) => void;
|
||||
/**
|
||||
* Callback to delete a runtime field from data view
|
||||
* @param fieldName name of the field to delete
|
||||
*/
|
||||
onDeleteField?: (fieldName: string) => void;
|
||||
|
||||
/**
|
||||
* Columns
|
||||
* Currently selected fields like table columns
|
||||
*/
|
||||
contextualFields: string[];
|
||||
|
||||
workspaceSelectedFieldNames?: string[];
|
||||
/**
|
||||
* Search by field name
|
||||
*/
|
||||
highlight?: string;
|
||||
|
||||
/**
|
||||
* Group index in the field list
|
||||
*/
|
||||
groupIndex: number;
|
||||
|
||||
/**
|
||||
* Item index in the field list
|
||||
*/
|
||||
itemIndex: number;
|
||||
}
|
||||
|
||||
function DiscoverFieldComponent({
|
||||
function UnifiedFieldListItemComponent({
|
||||
stateService,
|
||||
services,
|
||||
searchMode,
|
||||
alwaysShowActionButton = false,
|
||||
field,
|
||||
highlight,
|
||||
dataView,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
onAddFieldToWorkspace,
|
||||
onRemoveFieldFromWorkspace,
|
||||
onAddFilter,
|
||||
isEmpty,
|
||||
isSelected,
|
||||
|
@ -192,12 +206,11 @@ function DiscoverFieldComponent({
|
|||
multiFields,
|
||||
onEditField,
|
||||
onDeleteField,
|
||||
contextualFields,
|
||||
workspaceSelectedFieldNames,
|
||||
groupIndex,
|
||||
itemIndex,
|
||||
}: DiscoverFieldProps) {
|
||||
}: UnifiedFieldListItemProps) {
|
||||
const [infoIsOpen, setOpen] = useState(false);
|
||||
const isDocumentRecord = !!onAddFilter;
|
||||
|
||||
const addFilterAndClosePopover: typeof onAddFilter | undefined = useMemo(
|
||||
() =>
|
||||
|
@ -222,40 +235,41 @@ function DiscoverFieldComponent({
|
|||
(f, isCurrentlySelected) => {
|
||||
closePopover();
|
||||
if (isCurrentlySelected) {
|
||||
onRemoveField(f.name);
|
||||
onRemoveFieldFromWorkspace(f);
|
||||
} else {
|
||||
onAddField(f.name);
|
||||
onAddFieldToWorkspace(f);
|
||||
}
|
||||
},
|
||||
[onAddField, onRemoveField, closePopover]
|
||||
[onAddFieldToWorkspace, onRemoveFieldFromWorkspace, closePopover]
|
||||
);
|
||||
|
||||
const rawMultiFields = useMemo(() => multiFields?.map((f) => f.field), [multiFields]);
|
||||
|
||||
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(
|
||||
() => ({
|
||||
buttonAddFieldToWorkspaceProps: {
|
||||
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
|
||||
defaultMessage: 'Add field as column',
|
||||
}),
|
||||
},
|
||||
buttonAddFilterProps: {
|
||||
'data-test-subj': `discoverFieldListPanelAddExistFilter-${field.name}`,
|
||||
},
|
||||
buttonEditFieldProps: {
|
||||
'data-test-subj': `discoverFieldListPanelEdit-${field.name}`,
|
||||
},
|
||||
buttonDeleteFieldProps: {
|
||||
'data-test-subj': `discoverFieldListPanelDelete-${field.name}`,
|
||||
},
|
||||
}),
|
||||
[field.name]
|
||||
);
|
||||
const customPopoverHeaderProps: Partial<FieldPopoverHeaderProps> = useMemo(() => {
|
||||
const dataTestSubjPrefix =
|
||||
stateService.creationOptions.dataTestSubj?.fieldListItemPopoverHeaderDataTestSubjPrefix;
|
||||
return {
|
||||
buttonAddFieldToWorkspaceProps: stateService.creationOptions.buttonAddFieldToWorkspaceProps,
|
||||
...(dataTestSubjPrefix && {
|
||||
buttonAddFilterProps: {
|
||||
'data-test-subj': `${dataTestSubjPrefix}AddExistFilter-${field.name}`,
|
||||
},
|
||||
buttonEditFieldProps: {
|
||||
'data-test-subj': `${dataTestSubjPrefix}Edit-${field.name}`,
|
||||
},
|
||||
buttonDeleteFieldProps: {
|
||||
'data-test-subj': `${dataTestSubjPrefix}Delete-${field.name}`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}, [field.name, stateService.creationOptions]);
|
||||
|
||||
const renderPopover = () => {
|
||||
return (
|
||||
<>
|
||||
<DiscoverFieldStats
|
||||
<UnifiedFieldListItemStats
|
||||
stateService={stateService}
|
||||
services={services}
|
||||
field={field}
|
||||
multiFields={multiFields}
|
||||
dataView={dataView}
|
||||
|
@ -266,6 +280,7 @@ function DiscoverFieldComponent({
|
|||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<MultiFields
|
||||
stateService={stateService}
|
||||
multiFields={multiFields}
|
||||
alwaysShowActionButton={alwaysShowActionButton}
|
||||
toggleDisplay={toggleDisplay}
|
||||
|
@ -273,16 +288,18 @@ function DiscoverFieldComponent({
|
|||
</>
|
||||
)}
|
||||
|
||||
<FieldPopoverFooter
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
multiFields={rawMultiFields}
|
||||
trackUiMetric={trackUiMetric}
|
||||
contextualFields={contextualFields}
|
||||
originatingApp={PLUGIN_ID}
|
||||
uiActions={getUiActions()}
|
||||
closePopover={() => closePopover()}
|
||||
/>
|
||||
{!!services.uiActions && (
|
||||
<FieldPopoverFooter
|
||||
field={field}
|
||||
dataView={dataView}
|
||||
multiFields={rawMultiFields}
|
||||
trackUiMetric={trackUiMetric}
|
||||
contextualFields={workspaceSelectedFieldNames}
|
||||
originatingApp={stateService.creationOptions.originatingApp}
|
||||
uiActions={services.uiActions}
|
||||
closePopover={() => closePopover()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -308,24 +325,28 @@ function DiscoverFieldComponent({
|
|||
order={order}
|
||||
value={value}
|
||||
onDragStart={closePopover}
|
||||
isDisabled={alwaysShowActionButton}
|
||||
dataTestSubj={`dscFieldListPanelField-${field.name}`}
|
||||
isDisabled={
|
||||
alwaysShowActionButton || stateService.creationOptions.disableFieldListItemDragAndDrop
|
||||
}
|
||||
dataTestSubj={`${
|
||||
stateService.creationOptions.dataTestSubj?.fieldListItemDndDataTestSubjPrefix ??
|
||||
'unifiedFieldListItemDnD'
|
||||
}-${field.name}`}
|
||||
>
|
||||
<FieldItemButton
|
||||
size="xs"
|
||||
fieldSearchHighlight={highlight}
|
||||
className="dscSidebarItem"
|
||||
isEmpty={isEmpty}
|
||||
isActive={infoIsOpen}
|
||||
flush={alwaysShowActionButton ? 'both' : undefined}
|
||||
shouldAlwaysShowAction={alwaysShowActionButton}
|
||||
onClick={field.type !== '_source' ? togglePopover : undefined}
|
||||
{...getCommonFieldItemButtonProps({ field, isSelected, toggleDisplay })}
|
||||
{...getCommonFieldItemButtonProps({ stateService, field, isSelected, toggleDisplay })}
|
||||
/>
|
||||
</DragDrop>
|
||||
}
|
||||
closePopover={closePopover}
|
||||
data-test-subj="discoverFieldListPanelPopover"
|
||||
data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemPopoverDataTestSubj}
|
||||
renderHeader={() => (
|
||||
<FieldPopoverHeader
|
||||
field={field}
|
||||
|
@ -337,9 +358,9 @@ function DiscoverFieldComponent({
|
|||
{...customPopoverHeaderProps}
|
||||
/>
|
||||
)}
|
||||
renderContent={isDocumentRecord ? renderPopover : undefined}
|
||||
renderContent={searchMode === 'documents' ? renderPopover : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DiscoverField = memo(DiscoverFieldComponent);
|
||||
export const UnifiedFieldListItem = memo(UnifiedFieldListItemComponent);
|
|
@ -7,26 +7,32 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { FieldStats, FieldStatsProps } from '@kbn/unified-field-list/src/components/field_stats';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import type { CoreStart } from '@kbn/core-lifecycle-browser';
|
||||
import {
|
||||
useQuerySubscriber,
|
||||
hasQuerySubscriberData,
|
||||
} from '@kbn/unified-field-list/src/hooks/use_query_subscriber';
|
||||
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
FieldStats,
|
||||
type FieldStatsProps,
|
||||
type FieldStatsServices,
|
||||
} from '../../components/field_stats';
|
||||
import { useQuerySubscriber, hasQuerySubscriberData } from '../../hooks/use_query_subscriber';
|
||||
import type { UnifiedFieldListSidebarContainerStateService } from '../../types';
|
||||
|
||||
export interface DiscoverFieldStatsProps {
|
||||
export interface UnifiedFieldListItemStatsProps {
|
||||
stateService: UnifiedFieldListSidebarContainerStateService;
|
||||
field: DataViewField;
|
||||
services: Omit<FieldStatsServices, 'uiSettings'> & {
|
||||
core: CoreStart;
|
||||
};
|
||||
dataView: DataView;
|
||||
multiFields?: Array<{ field: DataViewField; isSelected: boolean }>;
|
||||
onAddFilter: FieldStatsProps['onAddFilter'];
|
||||
}
|
||||
|
||||
export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
|
||||
({ field, dataView, multiFields, onAddFilter }) => {
|
||||
const services = useDiscoverServices();
|
||||
export const UnifiedFieldListItemStats: React.FC<UnifiedFieldListItemStatsProps> = React.memo(
|
||||
({ stateService, services, field, dataView, multiFields, onAddFilter }) => {
|
||||
const querySubscriberResult = useQuerySubscriber({
|
||||
data: services.data,
|
||||
timeRangeUpdatesType: stateService.creationOptions.timeRangeUpdatesType,
|
||||
});
|
||||
// prioritize an aggregatable multi field if available or take the parent field
|
||||
const fieldForStats = useMemo(
|
||||
|
@ -37,20 +43,31 @@ export const DiscoverFieldStats: React.FC<DiscoverFieldStatsProps> = React.memo(
|
|||
[field, multiFields]
|
||||
);
|
||||
|
||||
const statsServices: FieldStatsServices = useMemo(
|
||||
() => ({
|
||||
data: services.data,
|
||||
dataViews: services.dataViews,
|
||||
fieldFormats: services.fieldFormats,
|
||||
charts: services.charts,
|
||||
uiSettings: services.core.uiSettings,
|
||||
}),
|
||||
[services]
|
||||
);
|
||||
|
||||
if (!hasQuerySubscriberData(querySubscriberResult)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldStats
|
||||
services={services}
|
||||
services={statsServices}
|
||||
query={querySubscriberResult.query}
|
||||
filters={querySubscriberResult.filters}
|
||||
fromDate={querySubscriberResult.fromDate}
|
||||
toDate={querySubscriberResult.toDate}
|
||||
dataViewOrDataViewId={dataView}
|
||||
field={fieldForStats}
|
||||
data-test-subj="dscFieldStats"
|
||||
data-test-subj={stateService.creationOptions.dataTestSubj?.fieldListItemStatsDataTestSubj}
|
||||
onAddFilter={onAddFilter}
|
||||
/>
|
||||
);
|
|
@ -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';
|
|
@ -1,4 +1,4 @@
|
|||
.dscSidebar {
|
||||
.unifiedFieldListSidebar {
|
||||
overflow: hidden;
|
||||
margin: 0 !important;
|
||||
flex-grow: 1;
|
||||
|
@ -13,7 +13,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dscSidebar__list {
|
||||
.unifiedFieldListSidebar__list {
|
||||
padding: $euiSizeS 0 $euiSizeS $euiSizeS;
|
||||
|
||||
@include euiBreakpoint('xs', 's') {
|
||||
|
@ -21,20 +21,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.dscSidebar__group {
|
||||
.unifiedFieldListSidebar__group {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dscSidebar__mobile {
|
||||
.unifiedFieldListSidebar__mobile {
|
||||
width: 100%;
|
||||
padding: $euiSizeS $euiSizeS 0;
|
||||
|
||||
.dscSidebar__mobileBadge {
|
||||
.unifiedFieldListSidebar__mobileBadge {
|
||||
margin-left: $euiSizeS;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.dscSidebar__flyoutHeader {
|
||||
.unifiedFieldListSidebar__flyoutHeader {
|
||||
align-items: center;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
|
@ -14,9 +14,9 @@ describe('group_fields', function () {
|
|||
it('should pick fields as unknown_selected if they are unknown', function () {
|
||||
const actual = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['currency'],
|
||||
workspaceSelectedFieldNames: ['currency'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -37,14 +37,14 @@ describe('group_fields', function () {
|
|||
it('should pick fields as nested for a nested field root', function () {
|
||||
const actual = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['nested1', 'bytes'],
|
||||
workspaceSelectedFieldNames: ['nested1', 'bytes'],
|
||||
allFields: [
|
||||
{
|
||||
name: 'nested1',
|
||||
type: 'nested',
|
||||
},
|
||||
] as DataViewField[],
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual.selectedFieldsMap).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -56,14 +56,19 @@ describe('group_fields', function () {
|
|||
|
||||
it('should work correctly if no columns selected', function () {
|
||||
expect(
|
||||
getSelectedFields({ dataView, columns: [], allFields: dataView.fields, isPlainRecord: false })
|
||||
getSelectedFields({
|
||||
dataView,
|
||||
workspaceSelectedFieldNames: [],
|
||||
allFields: dataView.fields,
|
||||
searchMode: 'documents',
|
||||
})
|
||||
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
|
||||
expect(
|
||||
getSelectedFields({
|
||||
dataView,
|
||||
columns: ['_source'],
|
||||
workspaceSelectedFieldNames: ['_source'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
})
|
||||
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
|
||||
});
|
||||
|
@ -71,9 +76,9 @@ describe('group_fields', function () {
|
|||
it('should pick fields into selected group', function () {
|
||||
const actual = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['bytes', '@timestamp'],
|
||||
workspaceSelectedFieldNames: ['bytes', '@timestamp'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
|
||||
expect(actual.selectedFieldsMap).toStrictEqual({
|
||||
|
@ -85,9 +90,9 @@ describe('group_fields', function () {
|
|||
it('should pick fields into selected group if they contain multifields', function () {
|
||||
const actual = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['machine.os', 'machine.os.raw'],
|
||||
workspaceSelectedFieldNames: ['machine.os', 'machine.os.raw'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual.selectedFields.map((field) => field.name)).toEqual([
|
||||
'machine.os',
|
||||
|
@ -102,9 +107,9 @@ describe('group_fields', function () {
|
|||
it('should sort selected fields by columns order', function () {
|
||||
const actual1 = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['bytes', 'extension.keyword', 'unknown'],
|
||||
workspaceSelectedFieldNames: ['bytes', 'extension.keyword', 'unknown'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual1.selectedFields.map((field) => field.name)).toEqual([
|
||||
'bytes',
|
||||
|
@ -119,9 +124,9 @@ describe('group_fields', function () {
|
|||
|
||||
const actual2 = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['extension', 'bytes', 'unknown'],
|
||||
workspaceSelectedFieldNames: ['extension', 'bytes', 'unknown'],
|
||||
allFields: dataView.fields,
|
||||
isPlainRecord: false,
|
||||
searchMode: 'documents',
|
||||
});
|
||||
expect(actual2.selectedFields.map((field) => field.name)).toEqual([
|
||||
'extension',
|
||||
|
@ -138,14 +143,14 @@ describe('group_fields', function () {
|
|||
it('should pick fields only from allFields instead of data view fields for a text based query', function () {
|
||||
const actual = getSelectedFields({
|
||||
dataView,
|
||||
columns: ['bytes'],
|
||||
workspaceSelectedFieldNames: ['bytes'],
|
||||
allFields: [
|
||||
{
|
||||
name: 'bytes',
|
||||
type: 'text',
|
||||
},
|
||||
] as DataViewField[],
|
||||
isPlainRecord: true,
|
||||
searchMode: 'text-based',
|
||||
});
|
||||
expect(actual).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
|
@ -163,30 +168,38 @@ describe('group_fields', function () {
|
|||
});
|
||||
|
||||
it('should show any fields if for text-based searches', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('bytes'), true)).toBe(true);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, true)).toBe(true);
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, true)).toBe(false);
|
||||
expect(shouldShowField(dataView.getFieldByName('bytes'), 'text-based', false)).toBe(true);
|
||||
expect(
|
||||
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'text-based', false)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'text-based', false)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should show fields excluding subfields when searched from source', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
|
||||
it('should show fields excluding subfields', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', false)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', false)).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', false)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', false)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should show fields excluding subfields when fields api is used', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
|
||||
expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
|
||||
it('should show fields including subfields', function () {
|
||||
expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', true)).toBe(true);
|
||||
expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', true)).toBe(
|
||||
true
|
||||
);
|
||||
expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', true)
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', true)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
|
@ -12,15 +12,24 @@ import {
|
|||
type DataView,
|
||||
getFieldSubtypeMulti,
|
||||
} from '@kbn/data-views-plugin/public';
|
||||
import type { SearchMode } from '../../types';
|
||||
|
||||
export function shouldShowField(field: DataViewField | undefined, isPlainRecord: boolean): boolean {
|
||||
export function shouldShowField(
|
||||
field: DataViewField | undefined,
|
||||
searchMode: SearchMode | undefined,
|
||||
disableMultiFieldsGroupingByParent: boolean | undefined
|
||||
): boolean {
|
||||
if (!field?.type || field.type === '_source') {
|
||||
return false;
|
||||
}
|
||||
if (isPlainRecord) {
|
||||
if (searchMode === 'text-based') {
|
||||
// exclude only `_source` for plain records
|
||||
return true;
|
||||
}
|
||||
if (disableMultiFieldsGroupingByParent) {
|
||||
// include subfields
|
||||
return true;
|
||||
}
|
||||
// exclude subfields
|
||||
return !getFieldSubtypeMulti(field?.spec);
|
||||
}
|
||||
|
@ -38,31 +47,36 @@ export interface SelectedFieldsResult {
|
|||
|
||||
export function getSelectedFields({
|
||||
dataView,
|
||||
columns,
|
||||
workspaceSelectedFieldNames,
|
||||
allFields,
|
||||
isPlainRecord,
|
||||
searchMode,
|
||||
}: {
|
||||
dataView: DataView | undefined;
|
||||
columns: string[];
|
||||
workspaceSelectedFieldNames?: string[];
|
||||
allFields: DataViewField[] | null;
|
||||
isPlainRecord: boolean;
|
||||
searchMode: SearchMode | undefined;
|
||||
}): SelectedFieldsResult {
|
||||
const result: SelectedFieldsResult = {
|
||||
selectedFields: [],
|
||||
selectedFieldsMap: {},
|
||||
};
|
||||
if (!Array.isArray(columns) || !columns.length || !allFields) {
|
||||
if (
|
||||
!workspaceSelectedFieldNames ||
|
||||
!Array.isArray(workspaceSelectedFieldNames) ||
|
||||
!workspaceSelectedFieldNames.length ||
|
||||
!allFields
|
||||
) {
|
||||
return INITIAL_SELECTED_FIELDS_RESULT;
|
||||
}
|
||||
|
||||
// add selected columns, that are not part of the data view, to be removable
|
||||
for (const column of columns) {
|
||||
// add selected field names, that are not part of the data view, to be removable
|
||||
for (const selectedFieldName of workspaceSelectedFieldNames) {
|
||||
const selectedField =
|
||||
(!isPlainRecord && dataView?.getFieldByName?.(column)) ||
|
||||
allFields.find((field) => field.name === column) || // for example to pick a `nested` root field or find a selected field in text-based response
|
||||
(searchMode === 'documents' && dataView?.getFieldByName?.(selectedFieldName)) ||
|
||||
allFields.find((field) => field.name === selectedFieldName) || // for example to pick a `nested` root field or find a selected field in text-based response
|
||||
({
|
||||
name: column,
|
||||
displayName: column,
|
||||
name: selectedFieldName,
|
||||
displayName: selectedFieldName,
|
||||
type: 'unknown_selected',
|
||||
} as DataViewField);
|
||||
result.selectedFields.push(selectedField);
|
|
@ -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 };
|
|
@ -14,9 +14,9 @@ import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common
|
|||
import { type DataViewsContract } from '@kbn/data-views-plugin/public';
|
||||
import {
|
||||
type FieldListGroups,
|
||||
type FieldsGroupDetails,
|
||||
type FieldsGroup,
|
||||
type FieldListItem,
|
||||
type OverrideFieldGroupDetails,
|
||||
FieldsGroupNames,
|
||||
ExistenceFetchStatus,
|
||||
} from '../types';
|
||||
|
@ -38,9 +38,7 @@ export interface GroupedFieldsParams<T extends FieldListItem> {
|
|||
popularFieldsLimit?: number;
|
||||
sortedSelectedFields?: T[];
|
||||
getCustomFieldType?: FieldFiltersParams<T>['getCustomFieldType'];
|
||||
onOverrideFieldGroupDetails?: (
|
||||
groupName: FieldsGroupNames
|
||||
) => Partial<FieldsGroupDetails> | undefined | null;
|
||||
onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
|
||||
onSupportedFieldFilter?: (field: T) => boolean;
|
||||
onSelectedFieldFilter?: (field: T) => boolean;
|
||||
}
|
||||
|
|
|
@ -9,14 +9,19 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
|
||||
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
|
||||
import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query';
|
||||
import { getResolvedDateRange } from '../utils/get_resolved_date_range';
|
||||
import type { TimeRangeUpdatesType, SearchMode } from '../types';
|
||||
|
||||
/**
|
||||
* Hook params
|
||||
*/
|
||||
export interface QuerySubscriberParams {
|
||||
data: DataPublicPluginStart;
|
||||
listenToSearchSessionUpdates?: boolean;
|
||||
/**
|
||||
* Pass `timefilter` only if you are not using search sessions for the global search
|
||||
*/
|
||||
timeRangeUpdatesType?: TimeRangeUpdatesType;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -27,17 +32,18 @@ export interface QuerySubscriberResult {
|
|||
filters: Filter[] | undefined;
|
||||
fromDate: string | undefined;
|
||||
toDate: string | undefined;
|
||||
searchMode: SearchMode | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memorizes current query, filters and absolute date range
|
||||
* @param data
|
||||
* @param listenToSearchSessionUpdates
|
||||
* @param timeRangeUpdatesType
|
||||
* @public
|
||||
*/
|
||||
export const useQuerySubscriber = ({
|
||||
data,
|
||||
listenToSearchSessionUpdates = true,
|
||||
timeRangeUpdatesType = 'search-session',
|
||||
}: QuerySubscriberParams) => {
|
||||
const timefilter = data.query.timefilter.timefilter;
|
||||
const [result, setResult] = useState<QuerySubscriberResult>(() => {
|
||||
|
@ -48,11 +54,12 @@ export const useQuerySubscriber = ({
|
|||
filters: state?.filters,
|
||||
fromDate: dateRange.fromDate,
|
||||
toDate: dateRange.toDate,
|
||||
searchMode: getSearchMode(state?.query),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!listenToSearchSessionUpdates) {
|
||||
if (timeRangeUpdatesType !== 'search-session') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -66,10 +73,10 @@ export const useQuerySubscriber = ({
|
|||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setResult, timefilter, data.search.session.state$, listenToSearchSessionUpdates]);
|
||||
}, [setResult, timefilter, data.search.session.state$, timeRangeUpdatesType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listenToSearchSessionUpdates) {
|
||||
if (timeRangeUpdatesType !== 'timefilter') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -83,7 +90,7 @@ export const useQuerySubscriber = ({
|
|||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [setResult, timefilter, listenToSearchSessionUpdates]);
|
||||
}, [setResult, timefilter, timeRangeUpdatesType]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = data.query.state$.subscribe(({ state, changes }) => {
|
||||
|
@ -92,6 +99,7 @@ export const useQuerySubscriber = ({
|
|||
...prevState,
|
||||
query: state.query,
|
||||
filters: state.filters,
|
||||
searchMode: getSearchMode(state.query),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
@ -114,4 +122,25 @@ export const hasQuerySubscriberData = (
|
|||
filters: Filter[];
|
||||
fromDate: string;
|
||||
toDate: string;
|
||||
} => Boolean(result.query && result.filters && result.fromDate && result.toDate);
|
||||
searchMode: SearchMode;
|
||||
} =>
|
||||
Boolean(result.query && result.filters && result.fromDate && result.toDate && result.searchMode);
|
||||
|
||||
/**
|
||||
* Determines current search mode
|
||||
* @param query
|
||||
*/
|
||||
export function getSearchMode(query?: Query | AggregateQuery): SearchMode | undefined {
|
||||
if (!query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
isOfAggregateQueryType(query) &&
|
||||
(getAggregateQueryMode(query) === 'sql' || getAggregateQueryMode(query) === 'esql')
|
||||
) {
|
||||
return 'text-based';
|
||||
}
|
||||
|
||||
return 'documents';
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { DataViewField } from '@kbn/data-views-plugin/common';
|
||||
import type { EuiButtonIconProps, EuiButtonProps } from '@elastic/eui';
|
||||
|
||||
export interface BucketedAggregation<KeyType = string> {
|
||||
buckets: Array<{
|
||||
|
@ -103,3 +104,93 @@ export interface RenderFieldItemParams<T extends FieldListItem> {
|
|||
groupName: FieldsGroupNames;
|
||||
fieldSearchHighlight?: string;
|
||||
}
|
||||
|
||||
export type OverrideFieldGroupDetails = (
|
||||
groupName: FieldsGroupNames
|
||||
) => Partial<FieldsGroupDetails> | undefined | null;
|
||||
|
||||
export type TimeRangeUpdatesType = 'search-session' | 'timefilter';
|
||||
|
||||
export type SearchMode = 'documents' | 'text-based';
|
||||
|
||||
export interface UnifiedFieldListSidebarContainerCreationOptions {
|
||||
/**
|
||||
* Plugin ID
|
||||
*/
|
||||
originatingApp: string;
|
||||
|
||||
/**
|
||||
* Your app name: "discover", "lens", etc. If not provided, sections state would not be persisted.
|
||||
*/
|
||||
localStorageKeyPrefix?: string;
|
||||
|
||||
/**
|
||||
* Pass `timefilter` only if you are not using search sessions for the global search
|
||||
*/
|
||||
timeRangeUpdatesType?: TimeRangeUpdatesType;
|
||||
|
||||
/**
|
||||
* Pass `true` to skip auto fetching of fields existence info
|
||||
*/
|
||||
disableFieldsExistenceAutoFetching?: boolean;
|
||||
|
||||
/**
|
||||
* Pass `true` to see all multi fields flattened in the list. Otherwise, they will show in a field popover.
|
||||
*/
|
||||
disableMultiFieldsGroupingByParent?: boolean;
|
||||
|
||||
/**
|
||||
* Pass `true` to not have "Popular Fields" section in the field list
|
||||
*/
|
||||
disablePopularFields?: boolean;
|
||||
|
||||
/**
|
||||
* Pass `true` to have non-draggable field list items (like in the mobile flyout)
|
||||
*/
|
||||
disableFieldListItemDragAndDrop?: boolean;
|
||||
|
||||
/**
|
||||
* This button will be shown in mobile view
|
||||
*/
|
||||
buttonPropsToTriggerFlyout?: Partial<EuiButtonProps>;
|
||||
|
||||
/**
|
||||
* Custom props like `aria-label`
|
||||
*/
|
||||
buttonAddFieldToWorkspaceProps?: Partial<EuiButtonIconProps>;
|
||||
|
||||
/**
|
||||
* Custom props like `aria-label`
|
||||
*/
|
||||
buttonRemoveFieldFromWorkspaceProps?: Partial<EuiButtonIconProps>;
|
||||
|
||||
/**
|
||||
* Return custom configuration for field list sections
|
||||
*/
|
||||
onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
|
||||
|
||||
/**
|
||||
* Use this predicate to hide certain fields
|
||||
* @param field
|
||||
*/
|
||||
onSupportedFieldFilter?: (field: DataViewField) => boolean;
|
||||
|
||||
/**
|
||||
* Custom `data-test-subj`. Mostly for preserving legacy values.
|
||||
*/
|
||||
dataTestSubj?: {
|
||||
fieldListAddFieldButtonTestSubj?: string;
|
||||
fieldListSidebarDataTestSubj?: string;
|
||||
fieldListItemStatsDataTestSubj?: string;
|
||||
fieldListItemDndDataTestSubjPrefix?: string;
|
||||
fieldListItemPopoverDataTestSubj?: string;
|
||||
fieldListItemPopoverHeaderDataTestSubjPrefix?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The service used to manage the state of the container
|
||||
*/
|
||||
export interface UnifiedFieldListSidebarContainerStateService {
|
||||
creationOptions: UnifiedFieldListSidebarContainerCreationOptions;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "target/types"
|
||||
},
|
||||
"include": ["*.ts", "src/**/*"],
|
||||
"include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"],
|
||||
"kbn_references": [
|
||||
"@kbn/i18n",
|
||||
"@kbn/data-views-plugin",
|
||||
|
@ -24,6 +24,9 @@
|
|||
"@kbn/field-types",
|
||||
"@kbn/ui-actions-browser",
|
||||
"@kbn/data-service",
|
||||
"@kbn/data-view-field-editor-plugin",
|
||||
"@kbn/dom-drag-drop",
|
||||
"@kbn/shared-ux-utility",
|
||||
],
|
||||
"exclude": ["target/**/*"]
|
||||
}
|
||||
|
|
|
@ -92,15 +92,55 @@ export function createDiscoverServicesMock(): DiscoverServices {
|
|||
return searchSource;
|
||||
});
|
||||
|
||||
const corePluginMock = coreMock.createStart();
|
||||
|
||||
const uiSettingsMock: Partial<typeof corePluginMock.uiSettings> = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
get: jest.fn((key: string): any => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
} else if (key === DEFAULT_COLUMNS_SETTING) {
|
||||
return ['default_column'];
|
||||
} else if (key === UI_SETTINGS.META_FIELDS) {
|
||||
return [];
|
||||
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
|
||||
return false;
|
||||
} else if (key === CONTEXT_STEP_SETTING) {
|
||||
return 5;
|
||||
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
|
||||
return 'desc';
|
||||
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
|
||||
return false;
|
||||
} else if (key === SAMPLE_SIZE_SETTING) {
|
||||
return 250;
|
||||
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
|
||||
return 150;
|
||||
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
|
||||
return 50;
|
||||
} else if (key === HIDE_ANNOUNCEMENTS) {
|
||||
return false;
|
||||
} else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
isDefault: jest.fn((key: string) => {
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
corePluginMock.uiSettings = {
|
||||
...corePluginMock.uiSettings,
|
||||
...uiSettingsMock,
|
||||
};
|
||||
|
||||
const theme = {
|
||||
theme$: of({ darkMode: false }),
|
||||
};
|
||||
|
||||
corePluginMock.theme = theme;
|
||||
|
||||
return {
|
||||
core: {
|
||||
...coreMock.createStart(),
|
||||
theme,
|
||||
},
|
||||
core: corePluginMock,
|
||||
charts: chartPluginMock.createSetupContract(),
|
||||
chrome: chromeServiceMock.createStartContract(),
|
||||
history: () => ({
|
||||
|
@ -128,50 +168,20 @@ export function createDiscoverServicesMock(): DiscoverServices {
|
|||
open: jest.fn(),
|
||||
},
|
||||
uiActions: uiActionsPluginMock.createStartContract(),
|
||||
uiSettings: {
|
||||
get: jest.fn((key: string) => {
|
||||
if (key === 'fields:popularLimit') {
|
||||
return 5;
|
||||
} else if (key === DEFAULT_COLUMNS_SETTING) {
|
||||
return ['default_column'];
|
||||
} else if (key === UI_SETTINGS.META_FIELDS) {
|
||||
return [];
|
||||
} else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
|
||||
return false;
|
||||
} else if (key === CONTEXT_STEP_SETTING) {
|
||||
return 5;
|
||||
} else if (key === SORT_DEFAULT_ORDER_SETTING) {
|
||||
return 'desc';
|
||||
} else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
|
||||
return false;
|
||||
} else if (key === SAMPLE_SIZE_SETTING) {
|
||||
return 250;
|
||||
} else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
|
||||
return 150;
|
||||
} else if (key === MAX_DOC_FIELDS_DISPLAYED) {
|
||||
return 50;
|
||||
} else if (key === HIDE_ANNOUNCEMENTS) {
|
||||
return false;
|
||||
} else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
isDefault: (key: string) => {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
uiSettings: uiSettingsMock,
|
||||
http: {
|
||||
basePath: '/',
|
||||
},
|
||||
dataViewEditor: {
|
||||
openEditor: jest.fn(),
|
||||
userPermissions: {
|
||||
editDataView: () => true,
|
||||
editDataView: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
dataViewFieldEditor: {
|
||||
openEditor: jest.fn(),
|
||||
userPermissions: {
|
||||
editIndexPattern: jest.fn(),
|
||||
editIndexPattern: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
navigation: {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import React from 'react';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import { EuiPageSidebar } from '@elastic/eui';
|
||||
import { mountWithIntl } from '@kbn/test-jest-helpers';
|
||||
import type { Query, AggregateQuery } from '@kbn/es-query';
|
||||
import { setHeaderActionMenuMounter } from '../../../../kibana_services';
|
||||
|
@ -31,7 +32,6 @@ import {
|
|||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { RequestAdapter } from '@kbn/inspector-plugin/common';
|
||||
import { DiscoverSidebar } from '../sidebar/discover_sidebar';
|
||||
import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock';
|
||||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { DiscoverServices } from '../../../../build_services';
|
||||
|
@ -164,17 +164,17 @@ describe('Discover component', () => {
|
|||
describe('sidebar', () => {
|
||||
test('should be opened if discover:sidebarClosed was not set', async () => {
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, undefined);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(1);
|
||||
expect(component.find(EuiPageSidebar).length).toBe(1);
|
||||
}, 10000);
|
||||
|
||||
test('should be opened if discover:sidebarClosed is false', async () => {
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, false);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(1);
|
||||
expect(component.find(EuiPageSidebar).length).toBe(1);
|
||||
}, 10000);
|
||||
|
||||
test('should be closed if discover:sidebarClosed is true', async () => {
|
||||
const component = await mountComponent(dataViewWithTimefieldMock, true);
|
||||
expect(component.find(DiscoverSidebar).length).toBe(0);
|
||||
expect(component.find(EuiPageSidebar).length).toBe(0);
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
|
|
|
@ -289,9 +289,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
|
|||
selectedDataView={dataView}
|
||||
isClosed={isSidebarClosed}
|
||||
trackUiMetric={trackUiMetric}
|
||||
useNewFieldsApi={useNewFieldsApi}
|
||||
onFieldEdited={onFieldEdited}
|
||||
viewMode={viewMode}
|
||||
onDataViewCreated={stateContainer.actions.onDataViewCreated}
|
||||
availableFields$={stateContainer.dataState.data$.availableFields$}
|
||||
/>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
.dscSidebarItem--multi {
|
||||
.kbnFieldButton__button {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -29,13 +29,39 @@ import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
|
|||
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
|
||||
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
|
||||
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
|
||||
import { VIEW_MODE } from '../../../../../common/constants';
|
||||
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list/src/services/field_existing/load_field_existing';
|
||||
import { resetExistingFieldsCache } from '@kbn/unified-field-list/src/hooks/use_existing_fields';
|
||||
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
|
||||
import type { AggregateQuery, Query } from '@kbn/es-query';
|
||||
import { buildDataTableRecord } from '../../../../utils/build_data_record';
|
||||
import { type DataTableRecord } from '../../../../types';
|
||||
import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
|
||||
import type { SearchBarCustomization } from '../../../../customizations';
|
||||
|
||||
const mockSearchBarCustomization: SearchBarCustomization = {
|
||||
id: 'search_bar',
|
||||
CustomDataViewPicker: jest
|
||||
.fn(() => <div data-test-subj="custom-data-view-picker" />)
|
||||
.mockName('CustomDataViewPickerMock'),
|
||||
};
|
||||
|
||||
let mockUseCustomizations = false;
|
||||
|
||||
jest.mock('../../../../customizations', () => ({
|
||||
...jest.requireActual('../../../../customizations'),
|
||||
useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
|
||||
if (!mockUseCustomizations) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case 'search_bar':
|
||||
return mockSearchBarCustomization;
|
||||
default:
|
||||
throw new Error(`Unknown customization id: ${id}`);
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
|
||||
loadFieldStats: jest.fn().mockResolvedValue({
|
||||
|
@ -89,11 +115,6 @@ function createMockServices() {
|
|||
}),
|
||||
},
|
||||
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
|
||||
dataViewEditor: {
|
||||
userPermissions: {
|
||||
editDataView: jest.fn(() => true),
|
||||
},
|
||||
},
|
||||
} as unknown as DiscoverServices;
|
||||
return mockServices;
|
||||
}
|
||||
|
@ -146,9 +167,7 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
|
|||
selectedDataView: dataView,
|
||||
trackUiMetric: jest.fn(),
|
||||
onFieldEdited: jest.fn(),
|
||||
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
|
||||
onDataViewCreated: jest.fn(),
|
||||
useNewFieldsApi: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -167,6 +186,7 @@ async function mountComponent(
|
|||
services?: DiscoverServices
|
||||
): Promise<ReactWrapper<DiscoverSidebarResponsiveProps>> {
|
||||
let comp: ReactWrapper<DiscoverSidebarResponsiveProps>;
|
||||
const appState = getAppStateContainer(appStateParams);
|
||||
const mockedServices = services ?? createMockServices();
|
||||
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () =>
|
||||
props.selectedDataView
|
||||
|
@ -176,11 +196,12 @@ async function mountComponent(
|
|||
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
|
||||
return [props.selectedDataView].find((d) => d!.id === id);
|
||||
});
|
||||
mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState());
|
||||
|
||||
await act(async () => {
|
||||
comp = await mountWithIntl(
|
||||
<KibanaContextProvider services={mockedServices}>
|
||||
<DiscoverAppStateProvider value={getAppStateContainer(appStateParams)}>
|
||||
<DiscoverAppStateProvider value={appState}>
|
||||
<DiscoverSidebarResponsive {...props} />
|
||||
</DiscoverAppStateProvider>
|
||||
</KibanaContextProvider>
|
||||
|
@ -204,6 +225,7 @@ describe('discover responsive sidebar', function () {
|
|||
existingFieldNames: Object.keys(mockfieldCounts),
|
||||
}));
|
||||
props = getCompProps();
|
||||
mockUseCustomizations = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -221,7 +243,16 @@ describe('discover responsive sidebar', function () {
|
|||
});
|
||||
});
|
||||
|
||||
const compLoadingExistence = await mountComponent(props);
|
||||
const compLoadingExistence = await mountComponent({
|
||||
...props,
|
||||
fieldListVariant: 'list-always',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
// wait for lazy modules
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await compLoadingExistence.update();
|
||||
});
|
||||
|
||||
expect(
|
||||
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
|
||||
|
@ -477,32 +508,44 @@ describe('discover responsive sidebar', function () {
|
|||
result: getDataTableRecords(stubLogstashDataView),
|
||||
textBasedQueryColumns: [
|
||||
{ id: '1', name: 'extension', meta: { type: 'text' } },
|
||||
{ id: '1', name: 'bytes', meta: { type: 'number' } },
|
||||
{ id: '1', name: '@timestamp', meta: { type: 'date' } },
|
||||
{ id: '2', name: 'bytes', meta: { type: 'number' } },
|
||||
{ id: '3', name: '@timestamp', meta: { type: 'date' } },
|
||||
],
|
||||
}) as DataDocuments$,
|
||||
};
|
||||
const compInViewerMode = await mountComponent(propsWithTextBasedMode, {
|
||||
const compInTextBasedMode = await mountComponent(propsWithTextBasedMode, {
|
||||
query: { sql: 'SELECT * FROM `index`' },
|
||||
});
|
||||
expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await compInTextBasedMode.update();
|
||||
});
|
||||
|
||||
expect(findTestSubject(compInTextBasedMode, 'indexPattern-add-field_btn').length).toBe(0);
|
||||
|
||||
const popularFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedPopularFields-count'
|
||||
);
|
||||
const selectedFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedSelectedFields-count'
|
||||
);
|
||||
const availableFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedAvailableFields-count'
|
||||
);
|
||||
const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count');
|
||||
const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count');
|
||||
const emptyFieldsCount = findTestSubject(
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedEmptyFields-count'
|
||||
);
|
||||
const metaFieldsCount = findTestSubject(
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedMetaFields-count'
|
||||
);
|
||||
const unmappedFieldsCount = findTestSubject(
|
||||
compInViewerMode,
|
||||
compInTextBasedMode,
|
||||
'fieldListGroupedUnmappedFields-count'
|
||||
);
|
||||
|
||||
|
@ -515,7 +558,7 @@ describe('discover responsive sidebar', function () {
|
|||
|
||||
expect(mockCalcFieldCounts.mock.calls.length).toBe(0);
|
||||
|
||||
expect(findTestSubject(compInViewerMode, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
expect(findTestSubject(compInTextBasedMode, 'fieldListGrouped__ariaDescription').text()).toBe(
|
||||
'2 selected fields. 3 available fields.'
|
||||
);
|
||||
});
|
||||
|
@ -548,9 +591,162 @@ describe('discover responsive sidebar', function () {
|
|||
|
||||
it('should not show "Add a field" button in viewer mode', async () => {
|
||||
const services = createMockServices();
|
||||
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
|
||||
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
|
||||
const compInViewerMode = await mountComponent(props, {}, services);
|
||||
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
|
||||
expect(findTestSubject(compInViewerMode, 'dataView-add-field_btn').length).toBe(0);
|
||||
});
|
||||
|
||||
it('should hide field list if documents status is not initialized', async function () {
|
||||
const comp = await mountComponent({
|
||||
...props,
|
||||
documents$: new BehaviorSubject({
|
||||
fetchStatus: FetchStatus.UNINITIALIZED,
|
||||
}) as DataDocuments$,
|
||||
});
|
||||
expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render "Add a field" button', async () => {
|
||||
const services = createMockServices();
|
||||
const comp = await mountComponent(
|
||||
{
|
||||
...props,
|
||||
fieldListVariant: 'list-always',
|
||||
},
|
||||
{},
|
||||
services
|
||||
);
|
||||
const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
|
||||
expect(addFieldButton.length).toBe(1);
|
||||
await addFieldButton.simulate('click');
|
||||
expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should render "Edit field" button', async () => {
|
||||
const services = createMockServices();
|
||||
const comp = await mountComponent(props, {}, services);
|
||||
const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
findTestSubject(availableFields, 'field-bytes').simulate('click');
|
||||
});
|
||||
await comp.update();
|
||||
const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes');
|
||||
expect(editFieldButton.length).toBe(1);
|
||||
await editFieldButton.simulate('click');
|
||||
expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not render Add/Edit field buttons in viewer mode', async () => {
|
||||
const services = createMockServices();
|
||||
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
|
||||
const compInViewerMode = await mountComponent(props, {}, services);
|
||||
const addFieldButton = findTestSubject(compInViewerMode, 'dataView-add-field_btn');
|
||||
expect(addFieldButton.length).toBe(0);
|
||||
const availableFields = findTestSubject(compInViewerMode, 'fieldListGroupedAvailableFields');
|
||||
await act(async () => {
|
||||
findTestSubject(availableFields, 'field-bytes').simulate('click');
|
||||
});
|
||||
const editFieldButton = findTestSubject(compInViewerMode, 'discoverFieldListPanelEdit-bytes');
|
||||
expect(editFieldButton.length).toBe(0);
|
||||
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render buttons in data view picker correctly', async () => {
|
||||
const services = createMockServices();
|
||||
const propsWithPicker: DiscoverSidebarResponsiveProps = {
|
||||
...props,
|
||||
fieldListVariant: 'button-and-flyout-always',
|
||||
};
|
||||
const compWithPicker = await mountComponent(propsWithPicker, {}, services);
|
||||
// open flyout
|
||||
await act(async () => {
|
||||
compWithPicker.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
|
||||
await compWithPicker.update();
|
||||
});
|
||||
|
||||
await compWithPicker.update();
|
||||
// open data view picker
|
||||
await findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click');
|
||||
expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1);
|
||||
// check "Add a field"
|
||||
const addFieldButtonInDataViewPicker = findTestSubject(
|
||||
compWithPicker,
|
||||
'indexPattern-add-field'
|
||||
);
|
||||
expect(addFieldButtonInDataViewPicker.length).toBe(1);
|
||||
// click "Create a data view"
|
||||
const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new');
|
||||
expect(createDataViewButton.length).toBe(1);
|
||||
await createDataViewButton.simulate('click');
|
||||
expect(services.dataViewEditor.openEditor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not render buttons in data view picker when in viewer mode', async () => {
|
||||
const services = createMockServices();
|
||||
services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
|
||||
services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
|
||||
const propsWithPicker: DiscoverSidebarResponsiveProps = {
|
||||
...props,
|
||||
fieldListVariant: 'button-and-flyout-always',
|
||||
};
|
||||
const compWithPickerInViewerMode = await mountComponent(propsWithPicker, {}, services);
|
||||
// open flyout
|
||||
await act(async () => {
|
||||
compWithPickerInViewerMode
|
||||
.find('.unifiedFieldListSidebar__mobileButton')
|
||||
.last()
|
||||
.simulate('click');
|
||||
await compWithPickerInViewerMode.update();
|
||||
});
|
||||
|
||||
await compWithPickerInViewerMode.update();
|
||||
// open data view picker
|
||||
findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click');
|
||||
expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1);
|
||||
// check that buttons are not present
|
||||
const addFieldButtonInDataViewPicker = findTestSubject(
|
||||
compWithPickerInViewerMode,
|
||||
'dataView-add-field'
|
||||
);
|
||||
expect(addFieldButtonInDataViewPicker.length).toBe(0);
|
||||
const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
|
||||
expect(createDataViewButton.length).toBe(0);
|
||||
});
|
||||
|
||||
describe('search bar customization', () => {
|
||||
it('should not render CustomDataViewPicker', async () => {
|
||||
mockUseCustomizations = false;
|
||||
const comp = await mountComponent({
|
||||
...props,
|
||||
fieldListVariant: 'button-and-flyout-always',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
|
||||
await comp.update();
|
||||
});
|
||||
|
||||
await comp.update();
|
||||
|
||||
expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should render CustomDataViewPicker', async () => {
|
||||
mockUseCustomizations = true;
|
||||
const comp = await mountComponent({
|
||||
...props,
|
||||
fieldListVariant: 'button-and-flyout-always',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
|
||||
await comp.update();
|
||||
});
|
||||
|
||||
await comp.update();
|
||||
|
||||
expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,26 +7,18 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButton,
|
||||
EuiFlyout,
|
||||
EuiFlyoutHeader,
|
||||
EuiHideFor,
|
||||
EuiIcon,
|
||||
EuiLink,
|
||||
EuiPortal,
|
||||
EuiShowFor,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
|
||||
import { useExistingFieldsFetcher, useQuerySubscriber } from '@kbn/unified-field-list';
|
||||
import { VIEW_MODE } from '../../../../../common/constants';
|
||||
import { DataViewPicker } from '@kbn/unified-search-plugin/public';
|
||||
import {
|
||||
UnifiedFieldListSidebarContainer,
|
||||
type UnifiedFieldListSidebarContainerProps,
|
||||
type UnifiedFieldListSidebarContainerApi,
|
||||
FieldsGroupNames,
|
||||
} from '@kbn/unified-field-list';
|
||||
import { PLUGIN_ID } from '../../../../../common';
|
||||
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
|
||||
import { DiscoverSidebar } from './discover_sidebar';
|
||||
import {
|
||||
AvailableFields$,
|
||||
DataDocuments$,
|
||||
|
@ -35,22 +27,58 @@ import {
|
|||
import { calcFieldCounts } from '../../utils/calc_field_counts';
|
||||
import { FetchStatus } from '../../../types';
|
||||
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
|
||||
import { getRawRecordType } from '../../utils/get_raw_record_type';
|
||||
import { useAppStateSelector } from '../../services/discover_app_state_container';
|
||||
import { getUiActions } from '../../../../kibana_services';
|
||||
import {
|
||||
discoverSidebarReducer,
|
||||
getInitialState,
|
||||
DiscoverSidebarReducerActionType,
|
||||
DiscoverSidebarReducerStatus,
|
||||
} from './lib/sidebar_reducer';
|
||||
import { useDiscoverCustomization } from '../../../../customizations';
|
||||
|
||||
const EMPTY_FIELD_COUNTS = {};
|
||||
|
||||
const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
|
||||
return {
|
||||
originatingApp: PLUGIN_ID,
|
||||
localStorageKeyPrefix: 'discover',
|
||||
disableFieldsExistenceAutoFetching: true,
|
||||
buttonPropsToTriggerFlyout: {
|
||||
contentProps: {
|
||||
id: DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields,
|
||||
},
|
||||
},
|
||||
buttonAddFieldToWorkspaceProps: {
|
||||
'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
|
||||
defaultMessage: 'Add field as column',
|
||||
}),
|
||||
},
|
||||
buttonRemoveFieldFromWorkspaceProps: {
|
||||
'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
|
||||
defaultMessage: 'Remove field from table',
|
||||
}),
|
||||
},
|
||||
onOverrideFieldGroupDetails: (groupName) => {
|
||||
if (groupName === FieldsGroupNames.AvailableFields) {
|
||||
return {
|
||||
helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', {
|
||||
defaultMessage: 'Fields available for display in the table.',
|
||||
}),
|
||||
};
|
||||
}
|
||||
},
|
||||
dataTestSubj: {
|
||||
fieldListAddFieldButtonTestSubj: 'dataView-add-field_btn',
|
||||
fieldListSidebarDataTestSubj: 'discover-sidebar',
|
||||
fieldListItemStatsDataTestSubj: 'dscFieldStats',
|
||||
fieldListItemDndDataTestSubjPrefix: 'dscFieldListPanelField',
|
||||
fieldListItemPopoverDataTestSubj: 'discoverFieldListPanelPopover',
|
||||
fieldListItemPopoverHeaderDataTestSubjPrefix: 'discoverFieldListPanel',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export interface DiscoverSidebarResponsiveProps {
|
||||
/**
|
||||
* Determines whether add/remove buttons are displayed non only when focused
|
||||
*/
|
||||
alwaysShowActionButtons?: boolean;
|
||||
/**
|
||||
* the selected columns displayed in the doc table in discover
|
||||
*/
|
||||
|
@ -90,10 +118,6 @@ export interface DiscoverSidebarResponsiveProps {
|
|||
* @param eventName
|
||||
*/
|
||||
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
|
||||
/**
|
||||
* Read from the Fields API
|
||||
*/
|
||||
useNewFieldsApi: boolean;
|
||||
/**
|
||||
* callback to execute on edit runtime field
|
||||
*/
|
||||
|
@ -102,14 +126,14 @@ export interface DiscoverSidebarResponsiveProps {
|
|||
* callback to execute on create dataview
|
||||
*/
|
||||
onDataViewCreated: (dataView: DataView) => void;
|
||||
/**
|
||||
* Discover view mode
|
||||
*/
|
||||
viewMode: VIEW_MODE;
|
||||
/**
|
||||
* list of available fields fetched from ES
|
||||
*/
|
||||
availableFields$: AvailableFields$;
|
||||
/**
|
||||
* For customization and testing purposes
|
||||
*/
|
||||
fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -119,12 +143,18 @@ export interface DiscoverSidebarResponsiveProps {
|
|||
*/
|
||||
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
|
||||
const services = useDiscoverServices();
|
||||
const { data, dataViews, core } = services;
|
||||
const isPlainRecord = useAppStateSelector(
|
||||
(state) => getRawRecordType(state.query) === RecordRawType.PLAIN
|
||||
);
|
||||
const { selectedDataView, onFieldEdited, onDataViewCreated } = props;
|
||||
const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
|
||||
const {
|
||||
fieldListVariant,
|
||||
selectedDataView,
|
||||
columns,
|
||||
trackUiMetric,
|
||||
onAddFilter,
|
||||
onFieldEdited,
|
||||
onDataViewCreated,
|
||||
onChangeDataView,
|
||||
onAddField,
|
||||
onRemoveField,
|
||||
} = props;
|
||||
const [sidebarState, dispatchSidebarStateAction] = useReducer(
|
||||
discoverSidebarReducer,
|
||||
selectedDataView,
|
||||
|
@ -132,6 +162,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
);
|
||||
const selectedDataViewRef = useRef<DataView | null | undefined>(selectedDataView);
|
||||
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
|
||||
const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
|
||||
useState<UnifiedFieldListSidebarContainerApi | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = props.documents$.subscribe((documentState) => {
|
||||
|
@ -196,38 +228,50 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
}
|
||||
}, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]);
|
||||
|
||||
const querySubscriberResult = useQuerySubscriber({ data });
|
||||
const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
|
||||
const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
|
||||
disableAutoFetching: true,
|
||||
dataViews: !isPlainRecord && sidebarState.dataView ? [sidebarState.dataView] : [],
|
||||
query: querySubscriberResult.query,
|
||||
filters: querySubscriberResult.filters,
|
||||
fromDate: querySubscriberResult.fromDate,
|
||||
toDate: querySubscriberResult.toDate,
|
||||
services: {
|
||||
data,
|
||||
dataViews,
|
||||
core,
|
||||
},
|
||||
});
|
||||
const refetchFieldsExistenceInfo =
|
||||
unifiedFieldListSidebarContainerApi?.refetchFieldsExistenceInfo;
|
||||
const scheduleFieldsExistenceInfoFetchRef = useRef<boolean>(false);
|
||||
|
||||
// Refetch fields existence info only after the fetch completes
|
||||
useEffect(() => {
|
||||
if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) {
|
||||
refetchFieldsExistenceInfo();
|
||||
}
|
||||
// refetching only if status changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sidebarState.status]);
|
||||
scheduleFieldsExistenceInfoFetchRef.current = false;
|
||||
|
||||
if (sidebarState.status !== DiscoverSidebarReducerStatus.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// refetching info only if status changed to completed
|
||||
|
||||
if (refetchFieldsExistenceInfo) {
|
||||
refetchFieldsExistenceInfo();
|
||||
} else {
|
||||
scheduleFieldsExistenceInfoFetchRef.current = true;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sidebarState.status, scheduleFieldsExistenceInfoFetchRef]);
|
||||
|
||||
// As unifiedFieldListSidebarContainerRef ref can be empty in the beginning,
|
||||
// we need to fetch the data once API becomes available and after documents are fetched
|
||||
const initializeUnifiedFieldListSidebarContainerApi = useCallback(
|
||||
(api) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheduleFieldsExistenceInfoFetchRef.current) {
|
||||
scheduleFieldsExistenceInfoFetchRef.current = false;
|
||||
api.refetchFieldsExistenceInfo();
|
||||
}
|
||||
|
||||
setUnifiedFieldListSidebarContainerApi(api);
|
||||
},
|
||||
[setUnifiedFieldListSidebarContainerApi, scheduleFieldsExistenceInfoFetchRef]
|
||||
);
|
||||
|
||||
const closeFieldEditor = useRef<() => void | undefined>();
|
||||
const closeDataViewEditor = useRef<() => void | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = () => {
|
||||
if (closeFieldEditor?.current) {
|
||||
closeFieldEditor?.current();
|
||||
}
|
||||
if (closeDataViewEditor?.current) {
|
||||
closeDataViewEditor?.current();
|
||||
}
|
||||
|
@ -238,24 +282,13 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
};
|
||||
}, []);
|
||||
|
||||
const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
|
||||
closeFieldEditor.current = ref;
|
||||
}, []);
|
||||
|
||||
const setDataViewEditorRef = useCallback((ref: () => void | undefined) => {
|
||||
closeDataViewEditor.current = ref;
|
||||
}, []);
|
||||
|
||||
const closeFlyout = useCallback(() => {
|
||||
setIsFlyoutVisible(false);
|
||||
}, []);
|
||||
|
||||
const { dataViewFieldEditor, dataViewEditor } = services;
|
||||
const { dataViewEditor } = services;
|
||||
const { availableFields$ } = props;
|
||||
|
||||
const canEditDataView =
|
||||
Boolean(dataViewEditor?.userPermissions.editDataView()) || !selectedDataView?.isPersisted();
|
||||
|
||||
useEffect(() => {
|
||||
// For an external embeddable like the Field stats
|
||||
// it is useful to know what fields are populated in the docs fetched
|
||||
|
@ -269,140 +302,96 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
|
|||
});
|
||||
}, [selectedDataView, sidebarState.fieldCounts, props.columns, availableFields$]);
|
||||
|
||||
const editField = useMemo(
|
||||
const canEditDataView =
|
||||
Boolean(dataViewEditor?.userPermissions.editDataView()) ||
|
||||
Boolean(selectedDataView && !selectedDataView.isPersisted());
|
||||
const closeFieldListFlyout = unifiedFieldListSidebarContainerApi?.closeFieldListFlyout;
|
||||
const createNewDataView = useMemo(
|
||||
() =>
|
||||
!isPlainRecord && canEditDataView && selectedDataView
|
||||
? (fieldName?: string) => {
|
||||
const ref = dataViewFieldEditor.openEditor({
|
||||
ctx: {
|
||||
dataView: selectedDataView,
|
||||
},
|
||||
fieldName,
|
||||
onSave: async () => {
|
||||
await onFieldEdited();
|
||||
canEditDataView
|
||||
? () => {
|
||||
const ref = dataViewEditor.openEditor({
|
||||
onSave: async (dataView) => {
|
||||
onDataViewCreated(dataView);
|
||||
},
|
||||
});
|
||||
if (setFieldEditorRef) {
|
||||
setFieldEditorRef(ref);
|
||||
}
|
||||
if (closeFlyout) {
|
||||
closeFlyout();
|
||||
if (setDataViewEditorRef) {
|
||||
setDataViewEditorRef(ref);
|
||||
}
|
||||
closeFieldListFlyout?.();
|
||||
}
|
||||
: undefined,
|
||||
[
|
||||
isPlainRecord,
|
||||
canEditDataView,
|
||||
dataViewFieldEditor,
|
||||
selectedDataView,
|
||||
setFieldEditorRef,
|
||||
closeFlyout,
|
||||
onFieldEdited,
|
||||
]
|
||||
[canEditDataView, dataViewEditor, setDataViewEditorRef, onDataViewCreated, closeFieldListFlyout]
|
||||
);
|
||||
|
||||
const createNewDataView = useCallback(() => {
|
||||
const ref = dataViewEditor.openEditor({
|
||||
onSave: async (dataView) => {
|
||||
onDataViewCreated(dataView);
|
||||
},
|
||||
});
|
||||
if (setDataViewEditorRef) {
|
||||
setDataViewEditorRef(ref);
|
||||
}
|
||||
if (closeFlyout) {
|
||||
closeFlyout();
|
||||
}
|
||||
}, [dataViewEditor, setDataViewEditorRef, closeFlyout, onDataViewCreated]);
|
||||
const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
|
||||
() => ({
|
||||
...services,
|
||||
uiActions: getUiActions(),
|
||||
}),
|
||||
[services]
|
||||
);
|
||||
|
||||
const searchBarCustomization = useDiscoverCustomization('search_bar');
|
||||
const CustomDataViewPicker = searchBarCustomization?.CustomDataViewPicker;
|
||||
|
||||
const createField = unifiedFieldListSidebarContainerApi?.createField;
|
||||
const prependDataViewPickerForMobile = useCallback(() => {
|
||||
return selectedDataView ? (
|
||||
CustomDataViewPicker ? (
|
||||
<CustomDataViewPicker />
|
||||
) : (
|
||||
<DataViewPicker
|
||||
currentDataViewId={selectedDataView.id}
|
||||
onChangeDataView={onChangeDataView}
|
||||
onAddField={createField}
|
||||
onDataViewCreated={createNewDataView}
|
||||
trigger={{
|
||||
label: selectedDataView?.getName() || '',
|
||||
'data-test-subj': 'dataView-switch-link',
|
||||
title: selectedDataView?.getIndexPattern() || '',
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null;
|
||||
}, [selectedDataView, createNewDataView, onChangeDataView, createField, CustomDataViewPicker]);
|
||||
|
||||
const onAddFieldToWorkspace = useCallback(
|
||||
(field: DataViewField) => {
|
||||
onAddField(field.name);
|
||||
},
|
||||
[onAddField]
|
||||
);
|
||||
|
||||
const onRemoveFieldFromWorkspace = useCallback(
|
||||
(field: DataViewField) => {
|
||||
onRemoveField(field.name);
|
||||
},
|
||||
[onRemoveField]
|
||||
);
|
||||
|
||||
if (!selectedDataView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!props.isClosed && (
|
||||
<EuiHideFor sizes={['xs', 's']}>
|
||||
<DiscoverSidebar
|
||||
{...props}
|
||||
isProcessing={isProcessing}
|
||||
onFieldEdited={onFieldEdited}
|
||||
allFields={sidebarState.allFields}
|
||||
editField={editField}
|
||||
createNewDataView={createNewDataView}
|
||||
showFieldList={showFieldList}
|
||||
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
|
||||
/>
|
||||
</EuiHideFor>
|
||||
)}
|
||||
<EuiShowFor sizes={['xs', 's']}>
|
||||
<div className="dscSidebar__mobile">
|
||||
<EuiButton
|
||||
contentProps={{
|
||||
className: 'dscSidebar__mobileButton',
|
||||
id: DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields,
|
||||
}}
|
||||
fullWidth
|
||||
onClick={() => setIsFlyoutVisible(true)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="discover.fieldChooser.fieldsMobileButtonLabel"
|
||||
defaultMessage="Fields"
|
||||
/>
|
||||
<EuiBadge
|
||||
className="dscSidebar__mobileBadge"
|
||||
color={props.columns[0] === '_source' ? 'default' : 'accent'}
|
||||
>
|
||||
{props.columns[0] === '_source' ? 0 : props.columns.length}
|
||||
</EuiBadge>
|
||||
</EuiButton>
|
||||
</div>
|
||||
{isFlyoutVisible && (
|
||||
<EuiPortal>
|
||||
<EuiFlyout
|
||||
size="s"
|
||||
onClose={() => setIsFlyoutVisible(false)}
|
||||
aria-labelledby="flyoutTitle"
|
||||
ownFocus
|
||||
>
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="s">
|
||||
<h2 id="flyoutTitle">
|
||||
<EuiLink color="text" onClick={() => setIsFlyoutVisible(false)}>
|
||||
<EuiIcon
|
||||
className="eui-alignBaseline"
|
||||
aria-label={i18n.translate('discover.fieldList.flyoutBackIcon', {
|
||||
defaultMessage: 'Back',
|
||||
})}
|
||||
type="arrowLeft"
|
||||
/>{' '}
|
||||
<strong>
|
||||
{i18n.translate('discover.fieldList.flyoutHeading', {
|
||||
defaultMessage: 'Field list',
|
||||
})}
|
||||
</strong>
|
||||
</EuiLink>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<DiscoverSidebar
|
||||
{...props}
|
||||
isProcessing={isProcessing}
|
||||
onFieldEdited={onFieldEdited}
|
||||
allFields={sidebarState.allFields}
|
||||
alwaysShowActionButtons={true}
|
||||
setFieldEditorRef={setFieldEditorRef}
|
||||
closeFlyout={closeFlyout}
|
||||
editField={editField}
|
||||
createNewDataView={createNewDataView}
|
||||
showDataViewPicker={true}
|
||||
showFieldList={showFieldList}
|
||||
isAffectedByGlobalFilter={isAffectedByGlobalFilter}
|
||||
/>
|
||||
</EuiFlyout>
|
||||
</EuiPortal>
|
||||
)}
|
||||
</EuiShowFor>
|
||||
</>
|
||||
<UnifiedFieldListSidebarContainer
|
||||
ref={initializeUnifiedFieldListSidebarContainerApi}
|
||||
variant={fieldListVariant}
|
||||
getCreationOptions={getCreationOptions}
|
||||
isSidebarCollapsed={props.isClosed}
|
||||
services={fieldListSidebarServices}
|
||||
dataView={selectedDataView}
|
||||
trackUiMetric={trackUiMetric}
|
||||
allFields={sidebarState.allFields}
|
||||
showFieldList={showFieldList}
|
||||
workspaceSelectedFieldNames={columns}
|
||||
onAddFieldToWorkspace={onAddFieldToWorkspace}
|
||||
onRemoveFieldFromWorkspace={onRemoveFieldFromWorkspace}
|
||||
onAddFilter={onAddFilter}
|
||||
onFieldEdited={onFieldEdited}
|
||||
prependInFlyout={prependDataViewPickerForMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,5 +6,4 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { DiscoverSidebar } from './discover_sidebar';
|
||||
export { DiscoverSidebarResponsive } from './discover_sidebar_responsive';
|
||||
|
|
|
@ -111,8 +111,8 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should return examples for non-aggregatable fields', async () => {
|
||||
await PageObjects.unifiedFieldList.clickFieldListItem('extension');
|
||||
it('should return examples for non-aggregatable or geo fields', async () => {
|
||||
await PageObjects.unifiedFieldList.clickFieldListItem('geo.coordinates');
|
||||
expect(await PageObjects.unifiedFieldList.getFieldStatsViewType()).to.be('exampleValues');
|
||||
expect(await PageObjects.unifiedFieldList.getFieldStatsDocsCount()).to.be(100);
|
||||
// actual hits might vary
|
||||
|
|
|
@ -2345,20 +2345,14 @@
|
|||
"discover.embeddable.inspectorRequestDescription": "Cette requête interroge Elasticsearch afin de récupérer les données pour la recherche.",
|
||||
"discover.embeddable.search.displayName": "rechercher",
|
||||
"discover.errorCalloutShowErrorMessage": "Afficher les détails",
|
||||
"discover.fieldChooser.addField.label": "Ajouter un champ",
|
||||
"discover.fieldChooser.availableFieldsTooltip": "Champs disponibles pour l'affichage dans le tableau.",
|
||||
"discover.fieldChooser.discoverField.actions": "Actions",
|
||||
"discover.fieldChooser.discoverField.addFieldTooltip": "Ajouter le champ en tant que colonne",
|
||||
"discover.fieldChooser.discoverField.multiField": "champ multiple",
|
||||
"discover.fieldChooser.discoverField.multiFields": "Champs multiples",
|
||||
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "Les champs multiples peuvent avoir plusieurs valeurs.",
|
||||
"discover.fieldChooser.discoverField.name": "Champ",
|
||||
"discover.fieldChooser.discoverField.removeFieldTooltip": "Supprimer le champ du tableau",
|
||||
"discover.fieldChooser.discoverField.value": "Valeur",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "Champs",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "Index et champs",
|
||||
"discover.fieldList.flyoutBackIcon": "Retour",
|
||||
"discover.fieldList.flyoutHeading": "Liste des champs",
|
||||
"discover.goToDiscoverButtonText": "Aller à Discover",
|
||||
"discover.grid.closePopover": "Fermer la fenêtre contextuelle",
|
||||
"discover.grid.copyCellValueButton": "Copier la valeur",
|
||||
|
|
|
@ -2345,20 +2345,14 @@
|
|||
"discover.embeddable.inspectorRequestDescription": "このリクエストはElasticsearchにクエリをかけ、検索データを取得します。",
|
||||
"discover.embeddable.search.displayName": "検索",
|
||||
"discover.errorCalloutShowErrorMessage": "詳細を表示",
|
||||
"discover.fieldChooser.addField.label": "フィールドを追加",
|
||||
"discover.fieldChooser.availableFieldsTooltip": "フィールドをテーブルに表示できます。",
|
||||
"discover.fieldChooser.discoverField.actions": "アクション",
|
||||
"discover.fieldChooser.discoverField.addFieldTooltip": "フィールドを列として追加",
|
||||
"discover.fieldChooser.discoverField.multiField": "複数フィールド",
|
||||
"discover.fieldChooser.discoverField.multiFields": "マルチフィールド",
|
||||
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "複数フィールドにはフィールドごとに複数の値を入力できます",
|
||||
"discover.fieldChooser.discoverField.name": "フィールド",
|
||||
"discover.fieldChooser.discoverField.removeFieldTooltip": "フィールドを表から削除",
|
||||
"discover.fieldChooser.discoverField.value": "値",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "フィールド",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "インデックスとフィールド",
|
||||
"discover.fieldList.flyoutBackIcon": "戻る",
|
||||
"discover.fieldList.flyoutHeading": "フィールドリスト",
|
||||
"discover.goToDiscoverButtonText": "Discoverに移動",
|
||||
"discover.grid.closePopover": "ポップオーバーを閉じる",
|
||||
"discover.grid.copyCellValueButton": "値をコピー",
|
||||
|
|
|
@ -2345,20 +2345,14 @@
|
|||
"discover.embeddable.inspectorRequestDescription": "此请求将查询 Elasticsearch 以获取搜索的数据。",
|
||||
"discover.embeddable.search.displayName": "搜索",
|
||||
"discover.errorCalloutShowErrorMessage": "显示详情",
|
||||
"discover.fieldChooser.addField.label": "添加字段",
|
||||
"discover.fieldChooser.availableFieldsTooltip": "适用于在表中显示的字段。",
|
||||
"discover.fieldChooser.discoverField.actions": "操作",
|
||||
"discover.fieldChooser.discoverField.addFieldTooltip": "将字段添加为列",
|
||||
"discover.fieldChooser.discoverField.multiField": "多字段",
|
||||
"discover.fieldChooser.discoverField.multiFields": "多字段",
|
||||
"discover.fieldChooser.discoverField.multiFieldTooltipContent": "多字段的每个字段可以有多个值",
|
||||
"discover.fieldChooser.discoverField.name": "字段",
|
||||
"discover.fieldChooser.discoverField.removeFieldTooltip": "从表中移除字段",
|
||||
"discover.fieldChooser.discoverField.value": "值",
|
||||
"discover.fieldChooser.fieldsMobileButtonLabel": "字段",
|
||||
"discover.fieldChooser.filter.indexAndFieldsSectionAriaLabel": "索引和字段",
|
||||
"discover.fieldList.flyoutBackIcon": "返回",
|
||||
"discover.fieldList.flyoutHeading": "字段列表",
|
||||
"discover.goToDiscoverButtonText": "前往 Discover",
|
||||
"discover.grid.closePopover": "关闭弹出框",
|
||||
"discover.grid.copyCellValueButton": "复制值",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue