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

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

## Summary

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

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

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

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

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

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

### Checklist

- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Davis McPhee <davismcphee@hotmail.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Julia Rechkunova 2023-07-10 12:18:40 +02:00 committed by GitHub
parent d8c8b7b0f0
commit ea53763028
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1799 additions and 1528 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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