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