(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 ;
+ };
+
+ const renderButtonVariant = () => {
+ return (
+ <>
+
+ setIsFieldListFlyoutVisible(true)}
+ >
+
+
+ {!workspaceSelectedFieldNames?.length || workspaceSelectedFieldNames[0] === '_source'
+ ? 0
+ : workspaceSelectedFieldNames.length}
+
+
+
+ {isFieldListFlyoutVisible && (
+
+ setIsFieldListFlyoutVisible(false)}
+ aria-labelledby="flyoutTitle"
+ ownFocus
+ >
+
+
+
+ setIsFieldListFlyoutVisible(false)}>
+ {' '}
+
+ {i18n.translate('unifiedFieldList.fieldListSidebar.flyoutHeading', {
+ defaultMessage: 'Field list',
+ })}
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+ };
+
+ if (variant === 'button-and-flyout-always') {
+ return renderButtonVariant();
+ }
+
+ if (variant === 'list-always') {
+ return (!isSidebarCollapsed && renderListVariant()) || null;
+ }
+
+ return (
+ <>
+ {!isSidebarCollapsed && {renderListVariant()}}
+ {renderButtonVariant()}
+ >
+ );
+});
+
+// Necessary for React.lazy
+// eslint-disable-next-line import/no-default-export
+export default UnifiedFieldListSidebarContainer;
diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.test.ts
similarity index 68%
rename from src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts
rename to packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.test.ts
index 53d0bc96d3ca8..ac9142aedd5ed 100644
--- a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.test.ts
+++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.test.ts
@@ -14,9 +14,9 @@ describe('group_fields', function () {
it('should pick fields as unknown_selected if they are unknown', function () {
const actual = getSelectedFields({
dataView,
- columns: ['currency'],
+ workspaceSelectedFieldNames: ['currency'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual).toMatchInlineSnapshot(`
Object {
@@ -37,14 +37,14 @@ describe('group_fields', function () {
it('should pick fields as nested for a nested field root', function () {
const actual = getSelectedFields({
dataView,
- columns: ['nested1', 'bytes'],
+ workspaceSelectedFieldNames: ['nested1', 'bytes'],
allFields: [
{
name: 'nested1',
type: 'nested',
},
] as DataViewField[],
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual.selectedFieldsMap).toMatchInlineSnapshot(`
Object {
@@ -56,14 +56,19 @@ describe('group_fields', function () {
it('should work correctly if no columns selected', function () {
expect(
- getSelectedFields({ dataView, columns: [], allFields: dataView.fields, isPlainRecord: false })
+ getSelectedFields({
+ dataView,
+ workspaceSelectedFieldNames: [],
+ allFields: dataView.fields,
+ searchMode: 'documents',
+ })
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
expect(
getSelectedFields({
dataView,
- columns: ['_source'],
+ workspaceSelectedFieldNames: ['_source'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
})
).toBe(INITIAL_SELECTED_FIELDS_RESULT);
});
@@ -71,9 +76,9 @@ describe('group_fields', function () {
it('should pick fields into selected group', function () {
const actual = getSelectedFields({
dataView,
- columns: ['bytes', '@timestamp'],
+ workspaceSelectedFieldNames: ['bytes', '@timestamp'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual.selectedFields.map((field) => field.name)).toEqual(['bytes', '@timestamp']);
expect(actual.selectedFieldsMap).toStrictEqual({
@@ -85,9 +90,9 @@ describe('group_fields', function () {
it('should pick fields into selected group if they contain multifields', function () {
const actual = getSelectedFields({
dataView,
- columns: ['machine.os', 'machine.os.raw'],
+ workspaceSelectedFieldNames: ['machine.os', 'machine.os.raw'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual.selectedFields.map((field) => field.name)).toEqual([
'machine.os',
@@ -102,9 +107,9 @@ describe('group_fields', function () {
it('should sort selected fields by columns order', function () {
const actual1 = getSelectedFields({
dataView,
- columns: ['bytes', 'extension.keyword', 'unknown'],
+ workspaceSelectedFieldNames: ['bytes', 'extension.keyword', 'unknown'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual1.selectedFields.map((field) => field.name)).toEqual([
'bytes',
@@ -119,9 +124,9 @@ describe('group_fields', function () {
const actual2 = getSelectedFields({
dataView,
- columns: ['extension', 'bytes', 'unknown'],
+ workspaceSelectedFieldNames: ['extension', 'bytes', 'unknown'],
allFields: dataView.fields,
- isPlainRecord: false,
+ searchMode: 'documents',
});
expect(actual2.selectedFields.map((field) => field.name)).toEqual([
'extension',
@@ -138,14 +143,14 @@ describe('group_fields', function () {
it('should pick fields only from allFields instead of data view fields for a text based query', function () {
const actual = getSelectedFields({
dataView,
- columns: ['bytes'],
+ workspaceSelectedFieldNames: ['bytes'],
allFields: [
{
name: 'bytes',
type: 'text',
},
] as DataViewField[],
- isPlainRecord: true,
+ searchMode: 'text-based',
});
expect(actual).toMatchInlineSnapshot(`
Object {
@@ -163,30 +168,38 @@ describe('group_fields', function () {
});
it('should show any fields if for text-based searches', function () {
- expect(shouldShowField(dataView.getFieldByName('bytes'), true)).toBe(true);
- expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, true)).toBe(true);
- expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, true)).toBe(false);
+ expect(shouldShowField(dataView.getFieldByName('bytes'), 'text-based', false)).toBe(true);
+ expect(
+ shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'text-based', false)
+ ).toBe(true);
+ expect(
+ shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'text-based', false)
+ ).toBe(false);
});
- it('should show fields excluding subfields when searched from source', function () {
- expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
- expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
- expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
- true
- );
- expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
+ it('should show fields excluding subfields', function () {
+ expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', false)).toBe(true);
+ expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', false)).toBe(
false
);
+ expect(
+ shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', false)
+ ).toBe(true);
+ expect(
+ shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', false)
+ ).toBe(false);
});
- it('should show fields excluding subfields when fields api is used', function () {
- expect(shouldShowField(dataView.getFieldByName('extension'), false)).toBe(true);
- expect(shouldShowField(dataView.getFieldByName('extension.keyword'), false)).toBe(false);
- expect(shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, false)).toBe(
+ it('should show fields including subfields', function () {
+ expect(shouldShowField(dataView.getFieldByName('extension'), 'documents', true)).toBe(true);
+ expect(shouldShowField(dataView.getFieldByName('extension.keyword'), 'documents', true)).toBe(
true
);
- expect(shouldShowField({ type: '_source', name: 'source' } as DataViewField, false)).toBe(
- false
- );
+ expect(
+ shouldShowField({ type: 'unknown', name: 'unknown' } as DataViewField, 'documents', true)
+ ).toBe(true);
+ expect(
+ shouldShowField({ type: '_source', name: 'source' } as DataViewField, 'documents', true)
+ ).toBe(false);
});
});
diff --git a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.tsx
similarity index 60%
rename from src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx
rename to packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.tsx
index 11bbd285f4b7e..30876651bb03b 100644
--- a/src/plugins/discover/public/application/main/components/sidebar/lib/group_fields.tsx
+++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/group_fields.tsx
@@ -12,15 +12,24 @@ import {
type DataView,
getFieldSubtypeMulti,
} from '@kbn/data-views-plugin/public';
+import type { SearchMode } from '../../types';
-export function shouldShowField(field: DataViewField | undefined, isPlainRecord: boolean): boolean {
+export function shouldShowField(
+ field: DataViewField | undefined,
+ searchMode: SearchMode | undefined,
+ disableMultiFieldsGroupingByParent: boolean | undefined
+): boolean {
if (!field?.type || field.type === '_source') {
return false;
}
- if (isPlainRecord) {
+ if (searchMode === 'text-based') {
// exclude only `_source` for plain records
return true;
}
+ if (disableMultiFieldsGroupingByParent) {
+ // include subfields
+ return true;
+ }
// exclude subfields
return !getFieldSubtypeMulti(field?.spec);
}
@@ -38,31 +47,36 @@ export interface SelectedFieldsResult {
export function getSelectedFields({
dataView,
- columns,
+ workspaceSelectedFieldNames,
allFields,
- isPlainRecord,
+ searchMode,
}: {
dataView: DataView | undefined;
- columns: string[];
+ workspaceSelectedFieldNames?: string[];
allFields: DataViewField[] | null;
- isPlainRecord: boolean;
+ searchMode: SearchMode | undefined;
}): SelectedFieldsResult {
const result: SelectedFieldsResult = {
selectedFields: [],
selectedFieldsMap: {},
};
- if (!Array.isArray(columns) || !columns.length || !allFields) {
+ if (
+ !workspaceSelectedFieldNames ||
+ !Array.isArray(workspaceSelectedFieldNames) ||
+ !workspaceSelectedFieldNames.length ||
+ !allFields
+ ) {
return INITIAL_SELECTED_FIELDS_RESULT;
}
- // add selected columns, that are not part of the data view, to be removable
- for (const column of columns) {
+ // add selected field names, that are not part of the data view, to be removable
+ for (const selectedFieldName of workspaceSelectedFieldNames) {
const selectedField =
- (!isPlainRecord && dataView?.getFieldByName?.(column)) ||
- allFields.find((field) => field.name === column) || // for example to pick a `nested` root field or find a selected field in text-based response
+ (searchMode === 'documents' && dataView?.getFieldByName?.(selectedFieldName)) ||
+ allFields.find((field) => field.name === selectedFieldName) || // for example to pick a `nested` root field or find a selected field in text-based response
({
- name: column,
- displayName: column,
+ name: selectedFieldName,
+ displayName: selectedFieldName,
type: 'unknown_selected',
} as DataViewField);
result.selectedFields.push(selectedField);
diff --git a/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/index.tsx b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/index.tsx
new file mode 100644
index 0000000000000..f93ff01714f60
--- /dev/null
+++ b/packages/kbn-unified-field-list/src/containers/unified_field_list_sidebar/index.tsx
@@ -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,
+
+
+
+
+
+);
+
+export type { UnifiedFieldListSidebarContainerProps, UnifiedFieldListSidebarContainerApi };
diff --git a/packages/kbn-unified-field-list/src/hooks/use_grouped_fields.ts b/packages/kbn-unified-field-list/src/hooks/use_grouped_fields.ts
index 45432fb9bba81..f51f9b0fffb2d 100644
--- a/packages/kbn-unified-field-list/src/hooks/use_grouped_fields.ts
+++ b/packages/kbn-unified-field-list/src/hooks/use_grouped_fields.ts
@@ -14,9 +14,9 @@ import { type DataView, type DataViewField } from '@kbn/data-views-plugin/common
import { type DataViewsContract } from '@kbn/data-views-plugin/public';
import {
type FieldListGroups,
- type FieldsGroupDetails,
type FieldsGroup,
type FieldListItem,
+ type OverrideFieldGroupDetails,
FieldsGroupNames,
ExistenceFetchStatus,
} from '../types';
@@ -38,9 +38,7 @@ export interface GroupedFieldsParams {
popularFieldsLimit?: number;
sortedSelectedFields?: T[];
getCustomFieldType?: FieldFiltersParams['getCustomFieldType'];
- onOverrideFieldGroupDetails?: (
- groupName: FieldsGroupNames
- ) => Partial | undefined | null;
+ onOverrideFieldGroupDetails?: OverrideFieldGroupDetails;
onSupportedFieldFilter?: (field: T) => boolean;
onSelectedFieldFilter?: (field: T) => boolean;
}
diff --git a/packages/kbn-unified-field-list/src/hooks/use_query_subscriber.ts b/packages/kbn-unified-field-list/src/hooks/use_query_subscriber.ts
index 7a3c9788ab162..1747746edc722 100644
--- a/packages/kbn-unified-field-list/src/hooks/use_query_subscriber.ts
+++ b/packages/kbn-unified-field-list/src/hooks/use_query_subscriber.ts
@@ -9,14 +9,19 @@
import { useEffect, useState } from 'react';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { AggregateQuery, Query, Filter } from '@kbn/es-query';
+import { getAggregateQueryMode, isOfAggregateQueryType } from '@kbn/es-query';
import { getResolvedDateRange } from '../utils/get_resolved_date_range';
+import type { TimeRangeUpdatesType, SearchMode } from '../types';
/**
* Hook params
*/
export interface QuerySubscriberParams {
data: DataPublicPluginStart;
- listenToSearchSessionUpdates?: boolean;
+ /**
+ * Pass `timefilter` only if you are not using search sessions for the global search
+ */
+ timeRangeUpdatesType?: TimeRangeUpdatesType;
}
/**
@@ -27,17 +32,18 @@ export interface QuerySubscriberResult {
filters: Filter[] | undefined;
fromDate: string | undefined;
toDate: string | undefined;
+ searchMode: SearchMode | undefined;
}
/**
* Memorizes current query, filters and absolute date range
* @param data
- * @param listenToSearchSessionUpdates
+ * @param timeRangeUpdatesType
* @public
*/
export const useQuerySubscriber = ({
data,
- listenToSearchSessionUpdates = true,
+ timeRangeUpdatesType = 'search-session',
}: QuerySubscriberParams) => {
const timefilter = data.query.timefilter.timefilter;
const [result, setResult] = useState(() => {
@@ -48,11 +54,12 @@ export const useQuerySubscriber = ({
filters: state?.filters,
fromDate: dateRange.fromDate,
toDate: dateRange.toDate,
+ searchMode: getSearchMode(state?.query),
};
});
useEffect(() => {
- if (!listenToSearchSessionUpdates) {
+ if (timeRangeUpdatesType !== 'search-session') {
return;
}
@@ -66,10 +73,10 @@ export const useQuerySubscriber = ({
});
return () => subscription.unsubscribe();
- }, [setResult, timefilter, data.search.session.state$, listenToSearchSessionUpdates]);
+ }, [setResult, timefilter, data.search.session.state$, timeRangeUpdatesType]);
useEffect(() => {
- if (listenToSearchSessionUpdates) {
+ if (timeRangeUpdatesType !== 'timefilter') {
return;
}
@@ -83,7 +90,7 @@ export const useQuerySubscriber = ({
});
return () => subscription.unsubscribe();
- }, [setResult, timefilter, listenToSearchSessionUpdates]);
+ }, [setResult, timefilter, timeRangeUpdatesType]);
useEffect(() => {
const subscription = data.query.state$.subscribe(({ state, changes }) => {
@@ -92,6 +99,7 @@ export const useQuerySubscriber = ({
...prevState,
query: state.query,
filters: state.filters,
+ searchMode: getSearchMode(state.query),
}));
}
});
@@ -114,4 +122,25 @@ export const hasQuerySubscriberData = (
filters: Filter[];
fromDate: string;
toDate: string;
-} => Boolean(result.query && result.filters && result.fromDate && result.toDate);
+ searchMode: SearchMode;
+} =>
+ Boolean(result.query && result.filters && result.fromDate && result.toDate && result.searchMode);
+
+/**
+ * Determines current search mode
+ * @param query
+ */
+export function getSearchMode(query?: Query | AggregateQuery): SearchMode | undefined {
+ if (!query) {
+ return undefined;
+ }
+
+ if (
+ isOfAggregateQueryType(query) &&
+ (getAggregateQueryMode(query) === 'sql' || getAggregateQueryMode(query) === 'esql')
+ ) {
+ return 'text-based';
+ }
+
+ return 'documents';
+}
diff --git a/packages/kbn-unified-field-list/src/types.ts b/packages/kbn-unified-field-list/src/types.ts
index d17a3a294446b..ab9c2af61171a 100755
--- a/packages/kbn-unified-field-list/src/types.ts
+++ b/packages/kbn-unified-field-list/src/types.ts
@@ -7,6 +7,7 @@
*/
import type { DataViewField } from '@kbn/data-views-plugin/common';
+import type { EuiButtonIconProps, EuiButtonProps } from '@elastic/eui';
export interface BucketedAggregation {
buckets: Array<{
@@ -103,3 +104,93 @@ export interface RenderFieldItemParams {
groupName: FieldsGroupNames;
fieldSearchHighlight?: string;
}
+
+export type OverrideFieldGroupDetails = (
+ groupName: FieldsGroupNames
+) => Partial | 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;
+
+ /**
+ * Custom props like `aria-label`
+ */
+ buttonAddFieldToWorkspaceProps?: Partial;
+
+ /**
+ * Custom props like `aria-label`
+ */
+ buttonRemoveFieldFromWorkspaceProps?: Partial;
+
+ /**
+ * 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;
+}
diff --git a/packages/kbn-unified-field-list/tsconfig.json b/packages/kbn-unified-field-list/tsconfig.json
index 8ace312498ae0..77edc97585a81 100644
--- a/packages/kbn-unified-field-list/tsconfig.json
+++ b/packages/kbn-unified-field-list/tsconfig.json
@@ -3,7 +3,7 @@
"compilerOptions": {
"outDir": "target/types"
},
- "include": ["*.ts", "src/**/*"],
+ "include": ["*.ts", "src/**/*", "__mocks__/**/*.ts"],
"kbn_references": [
"@kbn/i18n",
"@kbn/data-views-plugin",
@@ -24,6 +24,9 @@
"@kbn/field-types",
"@kbn/ui-actions-browser",
"@kbn/data-service",
+ "@kbn/data-view-field-editor-plugin",
+ "@kbn/dom-drag-drop",
+ "@kbn/shared-ux-utility",
],
"exclude": ["target/**/*"]
}
diff --git a/src/core/server/integration_tests/saved_objects/migrations/zdt_2/v2_to_zdt_partial_failure.test.ts b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/v2_to_zdt_partial_failure.test.ts
new file mode 100644
index 0000000000000..0cfebd3d8514b
--- /dev/null
+++ b/src/core/server/integration_tests/saved_objects/migrations/zdt_2/v2_to_zdt_partial_failure.test.ts
@@ -0,0 +1,173 @@
+/*
+ * 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 Path from 'path';
+import fs from 'fs/promises';
+import { range } from 'lodash';
+import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server';
+import { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server';
+import '../jest_matchers';
+import { getKibanaMigratorTestKit, startElasticsearch } from '../kibana_migrator_test_kit';
+import { delay, parseLogFile, createType } from '../test_utils';
+import { getBaseMigratorParams, noopMigration } from '../fixtures/zdt_base.fixtures';
+
+const logFilePath = Path.join(__dirname, 'v2_to_zdt_partial_failure.test.log');
+
+describe('ZDT with v2 compat - recovering from partially migrated state', () => {
+ let esServer: TestElasticsearchUtils['es'];
+
+ beforeAll(async () => {
+ await fs.unlink(logFilePath).catch(() => {});
+ esServer = await startElasticsearch();
+ });
+
+ afterAll(async () => {
+ await esServer?.stop();
+ await delay(10);
+ });
+
+ const typeBefore = createType({
+ name: 'switching_type',
+ mappings: {
+ properties: {
+ text: { type: 'text' },
+ keyword: { type: 'keyword' },
+ },
+ },
+ migrations: {
+ '7.0.0': noopMigration,
+ '7.5.0': noopMigration,
+ },
+ });
+
+ const typeFailingBetween = createType({
+ ...typeBefore,
+ switchToModelVersionAt: '8.0.0',
+ modelVersions: {
+ 1: {
+ changes: [
+ {
+ type: 'data_backfill',
+ backfillFn: (doc) => {
+ // this was the easiest way to simulate a migrate failure during doc mig.
+ throw new Error('need something to interrupt the migration');
+ },
+ },
+ ],
+ },
+ },
+ mappings: {
+ properties: {
+ text: { type: 'text' },
+ keyword: { type: 'keyword' },
+ newField: { type: 'text' },
+ },
+ },
+ });
+
+ const typeAfter = createType({
+ ...typeBefore,
+ switchToModelVersionAt: '8.0.0',
+ modelVersions: {
+ 1: {
+ changes: [
+ {
+ type: 'data_backfill',
+ backfillFn: (doc) => {
+ return { attributes: { newField: 'some value' } };
+ },
+ },
+ ],
+ },
+ },
+ mappings: {
+ properties: {
+ text: { type: 'text' },
+ keyword: { type: 'keyword' },
+ newField: { type: 'text' },
+ },
+ },
+ });
+
+ const createBaseline = async () => {
+ const { runMigrations, savedObjectsRepository } = await getKibanaMigratorTestKit({
+ ...getBaseMigratorParams({ migrationAlgorithm: 'v2', kibanaVersion: '8.9.0' }),
+ types: [typeBefore],
+ });
+ await runMigrations();
+
+ const sampleObjs = range(5).map((number) => ({
+ id: `doc-${number}`,
+ type: 'switching_type',
+ attributes: {
+ text: `text ${number}`,
+ keyword: `kw ${number}`,
+ },
+ }));
+
+ await savedObjectsRepository.bulkCreate(sampleObjs);
+ };
+
+ const runFailingMigration = async () => {
+ const { runMigrations } = await getKibanaMigratorTestKit({
+ ...getBaseMigratorParams(),
+ types: [typeFailingBetween],
+ });
+
+ await expect(runMigrations()).rejects.toBeDefined();
+ };
+
+ it('migrates the documents', async () => {
+ await createBaseline();
+ await runFailingMigration();
+
+ const { runMigrations, client, savedObjectsRepository } = await getKibanaMigratorTestKit({
+ ...getBaseMigratorParams(),
+ logFilePath,
+ types: [typeAfter],
+ });
+
+ await runMigrations();
+
+ const indices = await client.indices.get({ index: '.kibana*' });
+ expect(Object.keys(indices)).toEqual(['.kibana_8.9.0_001']);
+
+ const index = indices['.kibana_8.9.0_001'];
+ const mappings = index.mappings ?? {};
+ const mappingMeta = mappings._meta ?? {};
+
+ expect(mappings.properties).toEqual(
+ expect.objectContaining({
+ switching_type: typeAfter.mappings,
+ })
+ );
+
+ expect(mappingMeta.docVersions).toEqual({
+ switching_type: '10.1.0',
+ });
+
+ const { saved_objects: sampleDocs } = await savedObjectsRepository.find({
+ type: 'switching_type',
+ });
+
+ expect(sampleDocs).toHaveLength(5);
+
+ const records = await parseLogFile(logFilePath);
+ expect(records).toContainLogEntries(
+ [
+ 'current algo check result: v2-partially-migrated',
+ 'INIT -> INDEX_STATE_UPDATE_DONE',
+ 'INDEX_STATE_UPDATE_DONE -> DOCUMENTS_UPDATE_INIT',
+ 'Starting to process 5 documents.',
+ '-> DONE',
+ 'Migration completed',
+ ],
+ { ordered: true }
+ );
+ });
+});
diff --git a/src/plugins/discover/public/__mocks__/data_view.ts b/src/plugins/discover/public/__mocks__/data_view.ts
index d660eef2b9b38..b6f86109b580b 100644
--- a/src/plugins/discover/public/__mocks__/data_view.ts
+++ b/src/plugins/discover/public/__mocks__/data_view.ts
@@ -6,9 +6,9 @@
* Side Public License, v 1.
*/
-import { DataView } from '@kbn/data-views-plugin/public';
+import { DataView, DataViewField } from '@kbn/data-views-plugin/public';
-export const fields = [
+export const shallowMockedFields = [
{
name: '_source',
type: '_source',
@@ -73,6 +73,10 @@ export const fields = [
},
] as DataView['fields'];
+export const deepMockedFields = shallowMockedFields.map(
+ (field) => new DataViewField(field)
+) as DataView['fields'];
+
export const buildDataViewMock = ({
name,
fields: definedFields,
@@ -120,4 +124,7 @@ export const buildDataViewMock = ({
return dataView;
};
-export const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields });
+export const dataViewMock = buildDataViewMock({
+ name: 'the-data-view',
+ fields: shallowMockedFields,
+});
diff --git a/src/plugins/discover/public/__mocks__/services.ts b/src/plugins/discover/public/__mocks__/services.ts
index 745059cc1be23..8a624a0326936 100644
--- a/src/plugins/discover/public/__mocks__/services.ts
+++ b/src/plugins/discover/public/__mocks__/services.ts
@@ -92,15 +92,55 @@ export function createDiscoverServicesMock(): DiscoverServices {
return searchSource;
});
+ const corePluginMock = coreMock.createStart();
+
+ const uiSettingsMock: Partial = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ get: jest.fn((key: string): any => {
+ if (key === 'fields:popularLimit') {
+ return 5;
+ } else if (key === DEFAULT_COLUMNS_SETTING) {
+ return ['default_column'];
+ } else if (key === UI_SETTINGS.META_FIELDS) {
+ return [];
+ } else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
+ return false;
+ } else if (key === CONTEXT_STEP_SETTING) {
+ return 5;
+ } else if (key === SORT_DEFAULT_ORDER_SETTING) {
+ return 'desc';
+ } else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
+ return false;
+ } else if (key === SAMPLE_SIZE_SETTING) {
+ return 250;
+ } else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
+ return 150;
+ } else if (key === MAX_DOC_FIELDS_DISPLAYED) {
+ return 50;
+ } else if (key === HIDE_ANNOUNCEMENTS) {
+ return false;
+ } else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
+ return true;
+ }
+ }),
+ isDefault: jest.fn((key: string) => {
+ return true;
+ }),
+ };
+
+ corePluginMock.uiSettings = {
+ ...corePluginMock.uiSettings,
+ ...uiSettingsMock,
+ };
+
const theme = {
theme$: of({ darkMode: false }),
};
+ corePluginMock.theme = theme;
+
return {
- core: {
- ...coreMock.createStart(),
- theme,
- },
+ core: corePluginMock,
charts: chartPluginMock.createSetupContract(),
chrome: chromeServiceMock.createStartContract(),
history: () => ({
@@ -128,50 +168,20 @@ export function createDiscoverServicesMock(): DiscoverServices {
open: jest.fn(),
},
uiActions: uiActionsPluginMock.createStartContract(),
- uiSettings: {
- get: jest.fn((key: string) => {
- if (key === 'fields:popularLimit') {
- return 5;
- } else if (key === DEFAULT_COLUMNS_SETTING) {
- return ['default_column'];
- } else if (key === UI_SETTINGS.META_FIELDS) {
- return [];
- } else if (key === DOC_HIDE_TIME_COLUMN_SETTING) {
- return false;
- } else if (key === CONTEXT_STEP_SETTING) {
- return 5;
- } else if (key === SORT_DEFAULT_ORDER_SETTING) {
- return 'desc';
- } else if (key === FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE) {
- return false;
- } else if (key === SAMPLE_SIZE_SETTING) {
- return 250;
- } else if (key === SAMPLE_ROWS_PER_PAGE_SETTING) {
- return 150;
- } else if (key === MAX_DOC_FIELDS_DISPLAYED) {
- return 50;
- } else if (key === HIDE_ANNOUNCEMENTS) {
- return false;
- } else if (key === SEARCH_ON_PAGE_LOAD_SETTING) {
- return true;
- }
- }),
- isDefault: (key: string) => {
- return true;
- },
- },
+ uiSettings: uiSettingsMock,
http: {
basePath: '/',
},
dataViewEditor: {
+ openEditor: jest.fn(),
userPermissions: {
- editDataView: () => true,
+ editDataView: jest.fn(() => true),
},
},
dataViewFieldEditor: {
openEditor: jest.fn(),
userPermissions: {
- editIndexPattern: jest.fn(),
+ editIndexPattern: jest.fn(() => true),
},
},
navigation: {
diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx
index e7a0bd9f642d1..68c8b7ba0b318 100644
--- a/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx
+++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.test.tsx
@@ -8,6 +8,7 @@
import React from 'react';
import { BehaviorSubject, of } from 'rxjs';
+import { EuiPageSidebar } from '@elastic/eui';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import type { Query, AggregateQuery } from '@kbn/es-query';
import { setHeaderActionMenuMounter } from '../../../../kibana_services';
@@ -31,7 +32,6 @@ import {
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import { FetchStatus } from '../../../types';
import { RequestAdapter } from '@kbn/inspector-plugin/common';
-import { DiscoverSidebar } from '../sidebar/discover_sidebar';
import { LocalStorageMock } from '../../../../__mocks__/local_storage_mock';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { DiscoverServices } from '../../../../build_services';
@@ -164,17 +164,17 @@ describe('Discover component', () => {
describe('sidebar', () => {
test('should be opened if discover:sidebarClosed was not set', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, undefined);
- expect(component.find(DiscoverSidebar).length).toBe(1);
+ expect(component.find(EuiPageSidebar).length).toBe(1);
}, 10000);
test('should be opened if discover:sidebarClosed is false', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, false);
- expect(component.find(DiscoverSidebar).length).toBe(1);
+ expect(component.find(EuiPageSidebar).length).toBe(1);
}, 10000);
test('should be closed if discover:sidebarClosed is true', async () => {
const component = await mountComponent(dataViewWithTimefieldMock, true);
- expect(component.find(DiscoverSidebar).length).toBe(0);
+ expect(component.find(EuiPageSidebar).length).toBe(0);
}, 10000);
});
diff --git a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx
index 39bc6a76bbe16..b31feed5813fe 100644
--- a/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx
+++ b/src/plugins/discover/public/application/main/components/layout/discover_layout.tsx
@@ -289,9 +289,7 @@ export function DiscoverLayout({ stateContainer }: DiscoverLayoutProps) {
selectedDataView={dataView}
isClosed={isSidebarClosed}
trackUiMetric={trackUiMetric}
- useNewFieldsApi={useNewFieldsApi}
onFieldEdited={onFieldEdited}
- viewMode={viewMode}
onDataViewCreated={stateContainer.actions.onDataViewCreated}
availableFields$={stateContainer.dataState.data$.availableFields$}
/>
diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/main/components/sidebar/discover_field.scss
deleted file mode 100644
index 3e7a7a4d4ac1e..0000000000000
--- a/src/plugins/discover/public/application/main/components/sidebar/discover_field.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.dscSidebarItem--multi {
- .kbnFieldButton__button {
- padding-left: 0;
- }
-}
diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx
deleted file mode 100644
index cee5685d75254..0000000000000
--- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.test.tsx
+++ /dev/null
@@ -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>>, [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(() => ),
-};
-
-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 = {};
-
- 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> {
- let comp: ReactWrapper;
- 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(
-
-
-
-
-
- );
- // 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);
- });
- });
-});
diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx
deleted file mode 100644
index 37cccb45a174b..0000000000000
--- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar.tsx
+++ /dev/null
@@ -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(
- INITIAL_SELECTED_FIELDS_RESULT
- );
- const [multiFieldsMap, setMultiFieldsMap] = useState<
- Map> | 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['onSupportedFieldFilter'] =
- useCallback(
- (field) => {
- return shouldShowField(field, isPlainRecord);
- },
- [isPlainRecord]
- );
- const onOverrideFieldGroupDetails: GroupedFieldsParams['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['renderFieldItem'] = useCallback(
- ({ field, groupName, groupIndex, itemIndex, fieldSearchHighlight }) => (
-
-
-
- ),
- [
- alwaysShowActionButtons,
- selectedDataView,
- onAddField,
- onRemoveField,
- onAddFilter,
- trackUiMetric,
- multiFieldsMap,
- editField,
- deleteField,
- columns,
- selectedFieldsState.selectedFieldsMap,
- ]
- );
-
- const searchBarCustomization = useDiscoverCustomization('search_bar');
-
- if (!selectedDataView) {
- return null;
- }
-
- return (
-
- );
-}
-
-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>();
- 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;
-}
diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx
index cd3e6429ee4ce..a7c4006281291 100644
--- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx
+++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.test.tsx
@@ -29,13 +29,39 @@ import { stubLogstashDataView } from '@kbn/data-plugin/common/stubs';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock';
import { DiscoverAppStateProvider } from '../../services/discover_app_state_container';
-import { VIEW_MODE } from '../../../../../common/constants';
import * as ExistingFieldsServiceApi from '@kbn/unified-field-list/src/services/field_existing/load_field_existing';
import { resetExistingFieldsCache } from '@kbn/unified-field-list/src/hooks/use_existing_fields';
import { createDiscoverServicesMock } from '../../../../__mocks__/services';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { buildDataTableRecord } from '../../../../utils/build_data_record';
import { type DataTableRecord } from '../../../../types';
+import type { DiscoverCustomizationId } from '../../../../customizations/customization_service';
+import type { SearchBarCustomization } from '../../../../customizations';
+
+const mockSearchBarCustomization: SearchBarCustomization = {
+ id: 'search_bar',
+ CustomDataViewPicker: jest
+ .fn(() => )
+ .mockName('CustomDataViewPickerMock'),
+};
+
+let mockUseCustomizations = false;
+
+jest.mock('../../../../customizations', () => ({
+ ...jest.requireActual('../../../../customizations'),
+ useDiscoverCustomization: jest.fn((id: DiscoverCustomizationId) => {
+ if (!mockUseCustomizations) {
+ return undefined;
+ }
+
+ switch (id) {
+ case 'search_bar':
+ return mockSearchBarCustomization;
+ default:
+ throw new Error(`Unknown customization id: ${id}`);
+ }
+ }),
+}));
jest.mock('@kbn/unified-field-list/src/services/field_stats', () => ({
loadFieldStats: jest.fn().mockResolvedValue({
@@ -89,11 +115,6 @@ function createMockServices() {
}),
},
docLinks: { links: { discover: { fieldTypeHelp: '' } } },
- dataViewEditor: {
- userPermissions: {
- editDataView: jest.fn(() => true),
- },
- },
} as unknown as DiscoverServices;
return mockServices;
}
@@ -146,9 +167,7 @@ function getCompProps(options?: { hits?: DataTableRecord[] }): DiscoverSidebarRe
selectedDataView: dataView,
trackUiMetric: jest.fn(),
onFieldEdited: jest.fn(),
- viewMode: VIEW_MODE.DOCUMENT_LEVEL,
onDataViewCreated: jest.fn(),
- useNewFieldsApi: true,
};
}
@@ -167,6 +186,7 @@ async function mountComponent(
services?: DiscoverServices
): Promise> {
let comp: ReactWrapper;
+ const appState = getAppStateContainer(appStateParams);
const mockedServices = services ?? createMockServices();
mockedServices.data.dataViews.getIdsWithTitle = jest.fn(async () =>
props.selectedDataView
@@ -176,11 +196,12 @@ async function mountComponent(
mockedServices.data.dataViews.get = jest.fn().mockImplementation(async (id) => {
return [props.selectedDataView].find((d) => d!.id === id);
});
+ mockedServices.data.query.getState = jest.fn().mockImplementation(() => appState.getState());
await act(async () => {
comp = await mountWithIntl(
-
+
@@ -204,6 +225,7 @@ describe('discover responsive sidebar', function () {
existingFieldNames: Object.keys(mockfieldCounts),
}));
props = getCompProps();
+ mockUseCustomizations = false;
});
afterEach(() => {
@@ -221,7 +243,16 @@ describe('discover responsive sidebar', function () {
});
});
- const compLoadingExistence = await mountComponent(props);
+ const compLoadingExistence = await mountComponent({
+ ...props,
+ fieldListVariant: 'list-always',
+ });
+
+ await act(async () => {
+ // wait for lazy modules
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await compLoadingExistence.update();
+ });
expect(
findTestSubject(compLoadingExistence, 'fieldListGroupedAvailableFields-countLoading').exists()
@@ -477,32 +508,44 @@ describe('discover responsive sidebar', function () {
result: getDataTableRecords(stubLogstashDataView),
textBasedQueryColumns: [
{ id: '1', name: 'extension', meta: { type: 'text' } },
- { id: '1', name: 'bytes', meta: { type: 'number' } },
- { id: '1', name: '@timestamp', meta: { type: 'date' } },
+ { id: '2', name: 'bytes', meta: { type: 'number' } },
+ { id: '3', name: '@timestamp', meta: { type: 'date' } },
],
}) as DataDocuments$,
};
- const compInViewerMode = await mountComponent(propsWithTextBasedMode, {
+ const compInTextBasedMode = await mountComponent(propsWithTextBasedMode, {
query: { sql: 'SELECT * FROM `index`' },
});
- expect(findTestSubject(compInViewerMode, 'indexPattern-add-field_btn').length).toBe(0);
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await compInTextBasedMode.update();
+ });
+
+ expect(findTestSubject(compInTextBasedMode, 'indexPattern-add-field_btn').length).toBe(0);
const popularFieldsCount = findTestSubject(
- compInViewerMode,
+ compInTextBasedMode,
'fieldListGroupedPopularFields-count'
);
const selectedFieldsCount = findTestSubject(
- compInViewerMode,
+ compInTextBasedMode,
'fieldListGroupedSelectedFields-count'
);
const availableFieldsCount = findTestSubject(
- compInViewerMode,
+ compInTextBasedMode,
'fieldListGroupedAvailableFields-count'
);
- const emptyFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedEmptyFields-count');
- const metaFieldsCount = findTestSubject(compInViewerMode, 'fieldListGroupedMetaFields-count');
+ const emptyFieldsCount = findTestSubject(
+ compInTextBasedMode,
+ 'fieldListGroupedEmptyFields-count'
+ );
+ const metaFieldsCount = findTestSubject(
+ compInTextBasedMode,
+ 'fieldListGroupedMetaFields-count'
+ );
const unmappedFieldsCount = findTestSubject(
- compInViewerMode,
+ compInTextBasedMode,
'fieldListGroupedUnmappedFields-count'
);
@@ -515,7 +558,7 @@ describe('discover responsive sidebar', function () {
expect(mockCalcFieldCounts.mock.calls.length).toBe(0);
- expect(findTestSubject(compInViewerMode, 'fieldListGrouped__ariaDescription').text()).toBe(
+ expect(findTestSubject(compInTextBasedMode, 'fieldListGrouped__ariaDescription').text()).toBe(
'2 selected fields. 3 available fields.'
);
});
@@ -548,9 +591,162 @@ describe('discover responsive sidebar', function () {
it('should not show "Add a field" button in viewer mode', async () => {
const services = createMockServices();
- services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
+ services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
const compInViewerMode = await mountComponent(props, {}, services);
expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
expect(findTestSubject(compInViewerMode, 'dataView-add-field_btn').length).toBe(0);
});
+
+ it('should hide field list if documents status is not initialized', async function () {
+ const comp = await mountComponent({
+ ...props,
+ documents$: new BehaviorSubject({
+ fetchStatus: FetchStatus.UNINITIALIZED,
+ }) as DataDocuments$,
+ });
+ expect(findTestSubject(comp, 'fieldListGroupedFieldGroups').exists()).toBe(false);
+ });
+
+ it('should render "Add a field" button', async () => {
+ const services = createMockServices();
+ const comp = await mountComponent(
+ {
+ ...props,
+ fieldListVariant: 'list-always',
+ },
+ {},
+ services
+ );
+ const addFieldButton = findTestSubject(comp, 'dataView-add-field_btn');
+ expect(addFieldButton.length).toBe(1);
+ await addFieldButton.simulate('click');
+ expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render "Edit field" button', async () => {
+ const services = createMockServices();
+ const comp = await mountComponent(props, {}, services);
+ const availableFields = findTestSubject(comp, 'fieldListGroupedAvailableFields');
+ await act(async () => {
+ findTestSubject(availableFields, 'field-bytes').simulate('click');
+ });
+ await comp.update();
+ const editFieldButton = findTestSubject(comp, 'discoverFieldListPanelEdit-bytes');
+ expect(editFieldButton.length).toBe(1);
+ await editFieldButton.simulate('click');
+ expect(services.dataViewFieldEditor.openEditor).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not render Add/Edit field buttons in viewer mode', async () => {
+ const services = createMockServices();
+ services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
+ const compInViewerMode = await mountComponent(props, {}, services);
+ const addFieldButton = findTestSubject(compInViewerMode, 'dataView-add-field_btn');
+ expect(addFieldButton.length).toBe(0);
+ const availableFields = findTestSubject(compInViewerMode, 'fieldListGroupedAvailableFields');
+ await act(async () => {
+ findTestSubject(availableFields, 'field-bytes').simulate('click');
+ });
+ const editFieldButton = findTestSubject(compInViewerMode, 'discoverFieldListPanelEdit-bytes');
+ expect(editFieldButton.length).toBe(0);
+ expect(services.dataViewEditor.userPermissions.editDataView).toHaveBeenCalled();
+ });
+
+ it('should render buttons in data view picker correctly', async () => {
+ const services = createMockServices();
+ const propsWithPicker: DiscoverSidebarResponsiveProps = {
+ ...props,
+ fieldListVariant: 'button-and-flyout-always',
+ };
+ const compWithPicker = await mountComponent(propsWithPicker, {}, services);
+ // open flyout
+ await act(async () => {
+ compWithPicker.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
+ await compWithPicker.update();
+ });
+
+ await compWithPicker.update();
+ // open data view picker
+ await findTestSubject(compWithPicker, 'dataView-switch-link').simulate('click');
+ expect(findTestSubject(compWithPicker, 'changeDataViewPopover').length).toBe(1);
+ // check "Add a field"
+ const addFieldButtonInDataViewPicker = findTestSubject(
+ compWithPicker,
+ 'indexPattern-add-field'
+ );
+ expect(addFieldButtonInDataViewPicker.length).toBe(1);
+ // click "Create a data view"
+ const createDataViewButton = findTestSubject(compWithPicker, 'dataview-create-new');
+ expect(createDataViewButton.length).toBe(1);
+ await createDataViewButton.simulate('click');
+ expect(services.dataViewEditor.openEditor).toHaveBeenCalled();
+ });
+
+ it('should not render buttons in data view picker when in viewer mode', async () => {
+ const services = createMockServices();
+ services.dataViewEditor.userPermissions.editDataView = jest.fn(() => false);
+ services.dataViewFieldEditor.userPermissions.editIndexPattern = jest.fn(() => false);
+ const propsWithPicker: DiscoverSidebarResponsiveProps = {
+ ...props,
+ fieldListVariant: 'button-and-flyout-always',
+ };
+ const compWithPickerInViewerMode = await mountComponent(propsWithPicker, {}, services);
+ // open flyout
+ await act(async () => {
+ compWithPickerInViewerMode
+ .find('.unifiedFieldListSidebar__mobileButton')
+ .last()
+ .simulate('click');
+ await compWithPickerInViewerMode.update();
+ });
+
+ await compWithPickerInViewerMode.update();
+ // open data view picker
+ findTestSubject(compWithPickerInViewerMode, 'dataView-switch-link').simulate('click');
+ expect(findTestSubject(compWithPickerInViewerMode, 'changeDataViewPopover').length).toBe(1);
+ // check that buttons are not present
+ const addFieldButtonInDataViewPicker = findTestSubject(
+ compWithPickerInViewerMode,
+ 'dataView-add-field'
+ );
+ expect(addFieldButtonInDataViewPicker.length).toBe(0);
+ const createDataViewButton = findTestSubject(compWithPickerInViewerMode, 'dataview-create-new');
+ expect(createDataViewButton.length).toBe(0);
+ });
+
+ describe('search bar customization', () => {
+ it('should not render CustomDataViewPicker', async () => {
+ mockUseCustomizations = false;
+ const comp = await mountComponent({
+ ...props,
+ fieldListVariant: 'button-and-flyout-always',
+ });
+
+ await act(async () => {
+ comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
+ await comp.update();
+ });
+
+ await comp.update();
+
+ expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(false);
+ });
+
+ it('should render CustomDataViewPicker', async () => {
+ mockUseCustomizations = true;
+ const comp = await mountComponent({
+ ...props,
+ fieldListVariant: 'button-and-flyout-always',
+ });
+
+ await act(async () => {
+ comp.find('.unifiedFieldListSidebar__mobileButton').last().simulate('click');
+ await comp.update();
+ });
+
+ await comp.update();
+
+ expect(comp.find('[data-test-subj="custom-data-view-picker"]').exists()).toBe(true);
+ });
+ });
});
diff --git a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx
index ca841fe29f09d..f9a8b493a3f77 100644
--- a/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx
+++ b/src/plugins/discover/public/application/main/components/sidebar/discover_sidebar_responsive.tsx
@@ -7,26 +7,18 @@
*/
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
-import { i18n } from '@kbn/i18n';
-import { FormattedMessage } from '@kbn/i18n-react';
import { UiCounterMetricType } from '@kbn/analytics';
-import {
- EuiBadge,
- EuiButton,
- EuiFlyout,
- EuiFlyoutHeader,
- EuiHideFor,
- EuiIcon,
- EuiLink,
- EuiPortal,
- EuiShowFor,
- EuiTitle,
-} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
-import { useExistingFieldsFetcher, useQuerySubscriber } from '@kbn/unified-field-list';
-import { VIEW_MODE } from '../../../../../common/constants';
+import { DataViewPicker } from '@kbn/unified-search-plugin/public';
+import {
+ UnifiedFieldListSidebarContainer,
+ type UnifiedFieldListSidebarContainerProps,
+ type UnifiedFieldListSidebarContainerApi,
+ FieldsGroupNames,
+} from '@kbn/unified-field-list';
+import { PLUGIN_ID } from '../../../../../common';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
-import { DiscoverSidebar } from './discover_sidebar';
import {
AvailableFields$,
DataDocuments$,
@@ -35,22 +27,58 @@ import {
import { calcFieldCounts } from '../../utils/calc_field_counts';
import { FetchStatus } from '../../../types';
import { DISCOVER_TOUR_STEP_ANCHOR_IDS } from '../../../../components/discover_tour';
-import { getRawRecordType } from '../../utils/get_raw_record_type';
-import { useAppStateSelector } from '../../services/discover_app_state_container';
+import { getUiActions } from '../../../../kibana_services';
import {
discoverSidebarReducer,
getInitialState,
DiscoverSidebarReducerActionType,
DiscoverSidebarReducerStatus,
} from './lib/sidebar_reducer';
+import { useDiscoverCustomization } from '../../../../customizations';
const EMPTY_FIELD_COUNTS = {};
+const getCreationOptions: UnifiedFieldListSidebarContainerProps['getCreationOptions'] = () => {
+ return {
+ originatingApp: PLUGIN_ID,
+ localStorageKeyPrefix: 'discover',
+ disableFieldsExistenceAutoFetching: true,
+ buttonPropsToTriggerFlyout: {
+ contentProps: {
+ id: DISCOVER_TOUR_STEP_ANCHOR_IDS.addFields,
+ },
+ },
+ buttonAddFieldToWorkspaceProps: {
+ 'aria-label': i18n.translate('discover.fieldChooser.discoverField.addFieldTooltip', {
+ defaultMessage: 'Add field as column',
+ }),
+ },
+ buttonRemoveFieldFromWorkspaceProps: {
+ 'aria-label': i18n.translate('discover.fieldChooser.discoverField.removeFieldTooltip', {
+ defaultMessage: 'Remove field from table',
+ }),
+ },
+ onOverrideFieldGroupDetails: (groupName) => {
+ if (groupName === FieldsGroupNames.AvailableFields) {
+ return {
+ helpText: i18n.translate('discover.fieldChooser.availableFieldsTooltip', {
+ defaultMessage: 'Fields available for display in the table.',
+ }),
+ };
+ }
+ },
+ dataTestSubj: {
+ fieldListAddFieldButtonTestSubj: 'dataView-add-field_btn',
+ fieldListSidebarDataTestSubj: 'discover-sidebar',
+ fieldListItemStatsDataTestSubj: 'dscFieldStats',
+ fieldListItemDndDataTestSubjPrefix: 'dscFieldListPanelField',
+ fieldListItemPopoverDataTestSubj: 'discoverFieldListPanelPopover',
+ fieldListItemPopoverHeaderDataTestSubjPrefix: 'discoverFieldListPanel',
+ },
+ };
+};
+
export interface DiscoverSidebarResponsiveProps {
- /**
- * Determines whether add/remove buttons are displayed non only when focused
- */
- alwaysShowActionButtons?: boolean;
/**
* the selected columns displayed in the doc table in discover
*/
@@ -90,10 +118,6 @@ export interface DiscoverSidebarResponsiveProps {
* @param eventName
*/
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
- /**
- * Read from the Fields API
- */
- useNewFieldsApi: boolean;
/**
* callback to execute on edit runtime field
*/
@@ -102,14 +126,14 @@ export interface DiscoverSidebarResponsiveProps {
* callback to execute on create dataview
*/
onDataViewCreated: (dataView: DataView) => void;
- /**
- * Discover view mode
- */
- viewMode: VIEW_MODE;
/**
* list of available fields fetched from ES
*/
availableFields$: AvailableFields$;
+ /**
+ * For customization and testing purposes
+ */
+ fieldListVariant?: UnifiedFieldListSidebarContainerProps['variant'];
}
/**
@@ -119,12 +143,18 @@ export interface DiscoverSidebarResponsiveProps {
*/
export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps) {
const services = useDiscoverServices();
- const { data, dataViews, core } = services;
- const isPlainRecord = useAppStateSelector(
- (state) => getRawRecordType(state.query) === RecordRawType.PLAIN
- );
- const { selectedDataView, onFieldEdited, onDataViewCreated } = props;
- const [isFlyoutVisible, setIsFlyoutVisible] = useState(false);
+ const {
+ fieldListVariant,
+ selectedDataView,
+ columns,
+ trackUiMetric,
+ onAddFilter,
+ onFieldEdited,
+ onDataViewCreated,
+ onChangeDataView,
+ onAddField,
+ onRemoveField,
+ } = props;
const [sidebarState, dispatchSidebarStateAction] = useReducer(
discoverSidebarReducer,
selectedDataView,
@@ -132,6 +162,8 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
);
const selectedDataViewRef = useRef(selectedDataView);
const showFieldList = sidebarState.status !== DiscoverSidebarReducerStatus.INITIAL;
+ const [unifiedFieldListSidebarContainerApi, setUnifiedFieldListSidebarContainerApi] =
+ useState(null);
useEffect(() => {
const subscription = props.documents$.subscribe((documentState) => {
@@ -196,38 +228,50 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
}
}, [selectedDataView, dispatchSidebarStateAction, selectedDataViewRef]);
- const querySubscriberResult = useQuerySubscriber({ data });
- const isAffectedByGlobalFilter = Boolean(querySubscriberResult.filters?.length);
- const { isProcessing, refetchFieldsExistenceInfo } = useExistingFieldsFetcher({
- disableAutoFetching: true,
- dataViews: !isPlainRecord && sidebarState.dataView ? [sidebarState.dataView] : [],
- query: querySubscriberResult.query,
- filters: querySubscriberResult.filters,
- fromDate: querySubscriberResult.fromDate,
- toDate: querySubscriberResult.toDate,
- services: {
- data,
- dataViews,
- core,
- },
- });
+ const refetchFieldsExistenceInfo =
+ unifiedFieldListSidebarContainerApi?.refetchFieldsExistenceInfo;
+ const scheduleFieldsExistenceInfoFetchRef = useRef(false);
+ // Refetch fields existence info only after the fetch completes
useEffect(() => {
- if (sidebarState.status === DiscoverSidebarReducerStatus.COMPLETED) {
+ scheduleFieldsExistenceInfoFetchRef.current = false;
+
+ if (sidebarState.status !== DiscoverSidebarReducerStatus.COMPLETED) {
+ return;
+ }
+
+ // refetching info only if status changed to completed
+
+ if (refetchFieldsExistenceInfo) {
refetchFieldsExistenceInfo();
+ } else {
+ scheduleFieldsExistenceInfoFetchRef.current = true;
}
- // refetching only if status changes
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [sidebarState.status]);
+ }, [sidebarState.status, scheduleFieldsExistenceInfoFetchRef]);
+
+ // As unifiedFieldListSidebarContainerRef ref can be empty in the beginning,
+ // we need to fetch the data once API becomes available and after documents are fetched
+ const initializeUnifiedFieldListSidebarContainerApi = useCallback(
+ (api) => {
+ if (!api) {
+ return;
+ }
+
+ if (scheduleFieldsExistenceInfoFetchRef.current) {
+ scheduleFieldsExistenceInfoFetchRef.current = false;
+ api.refetchFieldsExistenceInfo();
+ }
+
+ setUnifiedFieldListSidebarContainerApi(api);
+ },
+ [setUnifiedFieldListSidebarContainerApi, scheduleFieldsExistenceInfoFetchRef]
+ );
- const closeFieldEditor = useRef<() => void | undefined>();
const closeDataViewEditor = useRef<() => void | undefined>();
useEffect(() => {
const cleanup = () => {
- if (closeFieldEditor?.current) {
- closeFieldEditor?.current();
- }
if (closeDataViewEditor?.current) {
closeDataViewEditor?.current();
}
@@ -238,24 +282,13 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
};
}, []);
- const setFieldEditorRef = useCallback((ref: () => void | undefined) => {
- closeFieldEditor.current = ref;
- }, []);
-
const setDataViewEditorRef = useCallback((ref: () => void | undefined) => {
closeDataViewEditor.current = ref;
}, []);
- const closeFlyout = useCallback(() => {
- setIsFlyoutVisible(false);
- }, []);
-
- const { dataViewFieldEditor, dataViewEditor } = services;
+ const { dataViewEditor } = services;
const { availableFields$ } = props;
- const canEditDataView =
- Boolean(dataViewEditor?.userPermissions.editDataView()) || !selectedDataView?.isPersisted();
-
useEffect(() => {
// For an external embeddable like the Field stats
// it is useful to know what fields are populated in the docs fetched
@@ -269,140 +302,96 @@ export function DiscoverSidebarResponsive(props: DiscoverSidebarResponsiveProps)
});
}, [selectedDataView, sidebarState.fieldCounts, props.columns, availableFields$]);
- const editField = useMemo(
+ const canEditDataView =
+ Boolean(dataViewEditor?.userPermissions.editDataView()) ||
+ Boolean(selectedDataView && !selectedDataView.isPersisted());
+ const closeFieldListFlyout = unifiedFieldListSidebarContainerApi?.closeFieldListFlyout;
+ const createNewDataView = useMemo(
() =>
- !isPlainRecord && canEditDataView && selectedDataView
- ? (fieldName?: string) => {
- const ref = dataViewFieldEditor.openEditor({
- ctx: {
- dataView: selectedDataView,
- },
- fieldName,
- onSave: async () => {
- await onFieldEdited();
+ canEditDataView
+ ? () => {
+ const ref = dataViewEditor.openEditor({
+ onSave: async (dataView) => {
+ onDataViewCreated(dataView);
},
});
- if (setFieldEditorRef) {
- setFieldEditorRef(ref);
- }
- if (closeFlyout) {
- closeFlyout();
+ if (setDataViewEditorRef) {
+ setDataViewEditorRef(ref);
}
+ closeFieldListFlyout?.();
}
: undefined,
- [
- isPlainRecord,
- canEditDataView,
- dataViewFieldEditor,
- selectedDataView,
- setFieldEditorRef,
- closeFlyout,
- onFieldEdited,
- ]
+ [canEditDataView, dataViewEditor, setDataViewEditorRef, onDataViewCreated, closeFieldListFlyout]
);
- const createNewDataView = useCallback(() => {
- const ref = dataViewEditor.openEditor({
- onSave: async (dataView) => {
- onDataViewCreated(dataView);
- },
- });
- if (setDataViewEditorRef) {
- setDataViewEditorRef(ref);
- }
- if (closeFlyout) {
- closeFlyout();
- }
- }, [dataViewEditor, setDataViewEditorRef, closeFlyout, onDataViewCreated]);
+ const fieldListSidebarServices: UnifiedFieldListSidebarContainerProps['services'] = useMemo(
+ () => ({
+ ...services,
+ uiActions: getUiActions(),
+ }),
+ [services]
+ );
+
+ const searchBarCustomization = useDiscoverCustomization('search_bar');
+ const CustomDataViewPicker = searchBarCustomization?.CustomDataViewPicker;
+
+ const createField = unifiedFieldListSidebarContainerApi?.createField;
+ const prependDataViewPickerForMobile = useCallback(() => {
+ return selectedDataView ? (
+ CustomDataViewPicker ? (
+
+ ) : (
+
+ )
+ ) : null;
+ }, [selectedDataView, createNewDataView, onChangeDataView, createField, CustomDataViewPicker]);
+
+ const onAddFieldToWorkspace = useCallback(
+ (field: DataViewField) => {
+ onAddField(field.name);
+ },
+ [onAddField]
+ );
+
+ const onRemoveFieldFromWorkspace = useCallback(
+ (field: DataViewField) => {
+ onRemoveField(field.name);
+ },
+ [onRemoveField]
+ );
if (!selectedDataView) {
return null;
}
return (
- <>
- {!props.isClosed && (
-
-
-
- )}
-
-
- setIsFlyoutVisible(true)}
- >
-
-
- {props.columns[0] === '_source' ? 0 : props.columns.length}
-
-
-
- {isFlyoutVisible && (
-
- setIsFlyoutVisible(false)}
- aria-labelledby="flyoutTitle"
- ownFocus
- >
-
-
-
- setIsFlyoutVisible(false)}>
- {' '}
-
- {i18n.translate('discover.fieldList.flyoutHeading', {
- defaultMessage: 'Field list',
- })}
-
-
-
-
-
-
-
-
- )}
-
- >
+
);
}
diff --git a/src/plugins/discover/public/application/main/components/sidebar/index.ts b/src/plugins/discover/public/application/main/components/sidebar/index.ts
index 9da7d31f96e86..02dc07596c704 100644
--- a/src/plugins/discover/public/application/main/components/sidebar/index.ts
+++ b/src/plugins/discover/public/application/main/components/sidebar/index.ts
@@ -6,5 +6,4 @@
* Side Public License, v 1.
*/
-export { DiscoverSidebar } from './discover_sidebar';
export { DiscoverSidebarResponsive } from './discover_sidebar_responsive';
diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx
index bd6529dd44314..76694ad53ec17 100644
--- a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx
+++ b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx
@@ -11,7 +11,7 @@ import { EuiCopy } from '@elastic/eui';
import { act } from 'react-dom/test-utils';
import { findTestSubject } from '@elastic/eui/lib/test';
import { esHits } from '../../__mocks__/es_hits';
-import { buildDataViewMock, fields } from '../../__mocks__/data_view';
+import { buildDataViewMock, deepMockedFields } from '../../__mocks__/data_view';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { DiscoverGrid, DiscoverGridProps } from './discover_grid';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
@@ -28,7 +28,7 @@ jest.mock('@kbn/cell-actions', () => ({
export const dataViewMock = buildDataViewMock({
name: 'the-data-view',
- fields,
+ fields: deepMockedFields,
timeFieldName: '@timestamp',
});
@@ -259,18 +259,8 @@ describe('DiscoverGrid', () => {
triggerId: 'test',
getCellValue: expect.any(Function),
fields: [
- {
- name: '@timestamp',
- type: 'date',
- aggregatable: true,
- searchable: undefined,
- },
- {
- name: 'message',
- type: 'string',
- aggregatable: false,
- searchable: undefined,
- },
+ dataViewMock.getFieldByName('@timestamp')?.toSpec(),
+ dataViewMock.getFieldByName('message')?.toSpec(),
],
})
);
diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
index 3d4acfc34d925..e05f4c9d704fa 100644
--- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
+++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx
@@ -456,23 +456,15 @@ export const DiscoverGrid = ({
const cellActionsFields = useMemo(
() =>
cellActionsTriggerId && !isPlainRecord
- ? visibleColumns.map((columnName) => {
- const field = dataView.getFieldByName(columnName);
- if (!field) {
- return {
+ ? visibleColumns.map(
+ (columnName) =>
+ dataView.getFieldByName(columnName)?.toSpec() ?? {
name: '',
type: '',
aggregatable: false,
searchable: false,
- };
- }
- return {
- name: columnName,
- type: field.type,
- aggregatable: field.aggregatable,
- searchable: field.searchable,
- };
- })
+ }
+ )
: undefined,
[cellActionsTriggerId, isPlainRecord, visibleColumns, dataView]
);
diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts
index 96edd34596706..dd66cf80eb8b5 100644
--- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts
+++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts
@@ -12,7 +12,6 @@ import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_ma
import { SearchInput } from '..';
import { getSavedSearchUrl } from '@kbn/saved-search-plugin/public';
import { DiscoverServices } from '../build_services';
-import { dataViewMock } from '../__mocks__/data_view';
import { discoverServiceMock } from '../__mocks__/services';
import { SavedSearchEmbeddable, SearchEmbeddableConfig } from './saved_search_embeddable';
import { render } from 'react-dom';
@@ -23,6 +22,7 @@ import { SHOW_FIELD_STATISTICS } from '../../common';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
import { VIEW_MODE } from '../../common/constants';
+import { buildDataViewMock, deepMockedFields } from '../__mocks__/data_view';
let discoverComponent: ReactWrapper;
@@ -48,6 +48,8 @@ function getSearchResponse(nrOfHits: number) {
});
}
+const dataViewMock = buildDataViewMock({ name: 'the-data-view', fields: deepMockedFields });
+
describe('saved search embeddable', () => {
let mountpoint: HTMLDivElement;
let filterManagerMock: jest.Mocked;
diff --git a/test/common/services/security/system_indices_user.ts b/test/common/services/security/system_indices_user.ts
index 9bfaddd9f0306..52e166c645093 100644
--- a/test/common/services/security/system_indices_user.ts
+++ b/test/common/services/security/system_indices_user.ts
@@ -68,8 +68,9 @@ export async function createSystemIndicesUser(ctx: FtrProviderContext) {
const enabled = !config
.get('esTestCluster.serverArgs')
.some((arg: string) => arg === 'xpack.security.enabled=false');
+ const isServerless = !!config.get('serverless');
- if (!enabled) {
+ if (!enabled || isServerless) {
return;
}
diff --git a/test/examples/unified_field_list_examples/field_stats.ts b/test/examples/unified_field_list_examples/field_stats.ts
index dc7aadf56d375..de662e737cb2d 100644
--- a/test/examples/unified_field_list_examples/field_stats.ts
+++ b/test/examples/unified_field_list_examples/field_stats.ts
@@ -111,8 +111,8 @@ export default ({ getService, getPageObjects }: FtrProviderContext) => {
);
});
- it('should return examples for non-aggregatable fields', async () => {
- await PageObjects.unifiedFieldList.clickFieldListItem('extension');
+ it('should return examples for non-aggregatable or geo fields', async () => {
+ await PageObjects.unifiedFieldList.clickFieldListItem('geo.coordinates');
expect(await PageObjects.unifiedFieldList.getFieldStatsViewType()).to.be('exampleValues');
expect(await PageObjects.unifiedFieldList.getFieldStatsDocsCount()).to.be(100);
// actual hits might vary
diff --git a/x-pack/packages/security-solution/data_table/components/data_table/data_table.stories.tsx b/x-pack/packages/security-solution/data_table/components/data_table/data_table.stories.tsx
index dde25228c7e82..d99dc31a8b655 100644
--- a/x-pack/packages/security-solution/data_table/components/data_table/data_table.stories.tsx
+++ b/x-pack/packages/security-solution/data_table/components/data_table/data_table.stories.tsx
@@ -84,6 +84,7 @@ export const DataTable = () => {
undefined}
data={mockTimelineData}
id={TableId.test}
renderCellValue={StoryCellRenderer}
diff --git a/x-pack/packages/security-solution/data_table/components/data_table/index.test.tsx b/x-pack/packages/security-solution/data_table/components/data_table/index.test.tsx
index 42523385b5ace..d543cca634c4a 100644
--- a/x-pack/packages/security-solution/data_table/components/data_table/index.test.tsx
+++ b/x-pack/packages/security-solution/data_table/components/data_table/index.test.tsx
@@ -71,6 +71,7 @@ describe('DataTable', () => {
const mount = useMountAppended();
const props: DataTableProps = {
browserFields: mockBrowserFields,
+ getFieldSpec: () => undefined,
data: mockTimelineData,
id: TableId.test,
loadPage: jest.fn(),
@@ -158,11 +159,21 @@ describe('DataTable', () => {
describe('cellActions', () => {
test('calls useDataGridColumnsCellActions properly', () => {
const data = mockTimelineData.slice(0, 1);
+ const timestampFieldSpec = {
+ name: '@timestamp',
+ type: 'date',
+ aggregatable: true,
+ esTypes: ['date'],
+ searchable: true,
+ };
const wrapper = mount(
+ timestampFieldSpec.name === name ? timestampFieldSpec : undefined
+ }
data={data}
/>
@@ -171,16 +182,7 @@ describe('DataTable', () => {
expect(mockUseDataGridColumnsCellActions).toHaveBeenCalledWith({
triggerId: 'mockCellActionsTrigger',
- fields: [
- {
- name: '@timestamp',
- type: 'date',
- aggregatable: true,
- esTypes: ['date'],
- searchable: true,
- subType: undefined,
- },
- ],
+ fields: [timestampFieldSpec],
getCellValue: expect.any(Function),
metadata: {
scopeId: 'table-test',
diff --git a/x-pack/packages/security-solution/data_table/components/data_table/index.tsx b/x-pack/packages/security-solution/data_table/components/data_table/index.tsx
index 6c71d796dc9ea..0018702ce5e0e 100644
--- a/x-pack/packages/security-solution/data_table/components/data_table/index.tsx
+++ b/x-pack/packages/security-solution/data_table/components/data_table/index.tsx
@@ -42,6 +42,7 @@ import {
useDataGridColumnsCellActions,
UseDataGridColumnsCellActionsProps,
} from '@kbn/cell-actions';
+import { FieldSpec } from '@kbn/data-views-plugin/common';
import { DataTableModel, DataTableState } from '../../store/data_table/types';
import { getColumnHeader, getColumnHeaders } from './column_headers/helpers';
@@ -96,6 +97,7 @@ interface BaseDataTableProps {
rowHeightsOptions?: EuiDataGridRowHeightsOptions;
isEventRenderedView?: boolean;
getFieldBrowser: GetFieldBrowser;
+ getFieldSpec: (fieldName: string) => FieldSpec | undefined;
cellActionsTriggerId?: string;
}
@@ -154,6 +156,7 @@ export const DataTableComponent = React.memo(
rowHeightsOptions,
isEventRenderedView = false,
getFieldBrowser,
+ getFieldSpec,
cellActionsTriggerId,
...otherProps
}) => {
@@ -331,21 +334,20 @@ export const DataTableComponent = React.memo(
);
const cellActionsMetadata = useMemo(() => ({ scopeId: id }), [id]);
-
const cellActionsFields = useMemo(
() =>
cellActionsTriggerId
- ? // TODO use FieldSpec object instead of column
- columnHeaders.map((column) => ({
- name: column.id,
- type: column.type ?? '', // When type is an empty string all cell actions are incompatible
- aggregatable: column.aggregatable ?? false,
- searchable: column.searchable ?? false,
- esTypes: column.esTypes ?? [],
- subType: column.subType,
- }))
+ ? columnHeaders.map(
+ (column) =>
+ getFieldSpec(column.id) ?? {
+ name: column.id,
+ type: '', // When type is an empty string all cell actions are incompatible
+ aggregatable: false,
+ searchable: false,
+ }
+ )
: undefined,
- [cellActionsTriggerId, columnHeaders]
+ [cellActionsTriggerId, columnHeaders, getFieldSpec]
);
const getCellValue = useCallback(
diff --git a/x-pack/packages/security-solution/data_table/tsconfig.json b/x-pack/packages/security-solution/data_table/tsconfig.json
index da4167b70ade2..473470dab1083 100644
--- a/x-pack/packages/security-solution/data_table/tsconfig.json
+++ b/x-pack/packages/security-solution/data_table/tsconfig.json
@@ -19,6 +19,7 @@
"@kbn/kibana-react-plugin",
"@kbn/kibana-utils-plugin",
"@kbn/i18n-react",
- "@kbn/ui-actions-plugin"
+ "@kbn/ui-actions-plugin",
+ "@kbn/data-views-plugin"
]
}
diff --git a/x-pack/plugins/apm/public/components/routing/app_root/apm_header_action_menu/index.tsx b/x-pack/plugins/apm/public/components/routing/app_root/apm_header_action_menu/index.tsx
index 320ee883462ae..a86a8ac459978 100644
--- a/x-pack/plugins/apm/public/components/routing/app_root/apm_header_action_menu/index.tsx
+++ b/x-pack/plugins/apm/public/components/routing/app_root/apm_header_action_menu/index.tsx
@@ -23,11 +23,12 @@ import { InspectorHeaderLink } from './inspector_header_link';
import { Labs } from './labs';
export function ApmHeaderActionMenu() {
- const { core, plugins } = useApmPluginContext();
+ const { core, plugins, config } = useApmPluginContext();
const { search } = window.location;
const { application, http } = core;
const { basePath } = http;
const { capabilities } = application;
+ const { featureFlags } = config;
const canReadMlJobs = !!capabilities.ml?.canGetJobs;
const canCreateMlJobs = !!capabilities.ml?.canCreateJob;
const { isAlertingAvailable, canReadAlerts, canSaveAlerts } =
@@ -50,19 +51,22 @@ export function ApmHeaderActionMenu() {
return (
{isLabsButtonEnabled && }
-
-
-
- {i18n.translate('xpack.apm.storageExplorerLinkLabel', {
- defaultMessage: 'Storage Explorer',
- })}
-
-
-
+ {featureFlags.storageExplorerAvailable && (
+
+
+
+ {i18n.translate('xpack.apm.storageExplorerLinkLabel', {
+ defaultMessage: 'Storage Explorer',
+ })}
+
+
+
+ )}
+
{canCreateMlJobs && }
{isAlertingAvailable && (
{
const config = this.initializerContext.config.get();
const pluginSetupDeps = plugins;
+ const { featureFlags } = config;
+
if (pluginSetupDeps.home) {
pluginSetupDeps.home.environment.update({ apmUi: true });
pluginSetupDeps.home.featureCatalogue.register(featureCatalogueEntry);
@@ -365,6 +367,7 @@ export class ApmPlugin implements Plugin {
id: 'storage-explorer',
title: apmStorageExplorerTitle,
path: '/storage-explorer',
+ searchable: featureFlags.storageExplorerAvailable,
},
],
diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration/create_or_update_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration/create_or_update_configuration.ts
index 666195217ccdc..5520b51137235 100644
--- a/x-pack/plugins/apm/server/routes/settings/agent_configuration/create_or_update_configuration.ts
+++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration/create_or_update_configuration.ts
@@ -26,7 +26,7 @@ export function createOrUpdateConfiguration({
internalESClient: APMInternalESClient;
}) {
const params: APMIndexDocumentParams = {
- refresh: true,
+ refresh: 'wait_for' as const,
index: APM_AGENT_CONFIGURATION_INDEX,
body: {
agent_name: configurationIntake.agent_name,
diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.test.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.test.ts
index 4d768cdff09d4..7a21db4ee5bf1 100644
--- a/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.test.ts
+++ b/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.test.ts
@@ -41,7 +41,7 @@ describe('Create or Update Custom link', () => {
expect(internalClientIndexMock).toHaveBeenCalledWith(
'create_or_update_custom_link',
{
- refresh: true,
+ refresh: 'wait_for',
index: '.apm-custom-link',
body: {
'@timestamp': 1570737000000,
@@ -62,7 +62,7 @@ describe('Create or Update Custom link', () => {
expect(internalClientIndexMock).toHaveBeenCalledWith(
'create_or_update_custom_link',
{
- refresh: true,
+ refresh: 'wait_for',
index: '.apm-custom-link',
id: 'bar',
body: {
diff --git a/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.ts b/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.ts
index 1bda27f7eb78b..979c16ad53473 100644
--- a/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.ts
+++ b/x-pack/plugins/apm/server/routes/settings/custom_link/create_or_update_custom_link.ts
@@ -26,7 +26,7 @@ export function createOrUpdateCustomLink({
internalESClient: APMInternalESClient;
}) {
const params: APMIndexDocumentParams = {
- refresh: true,
+ refresh: 'wait_for' as const,
index: APM_CUSTOM_LINK_INDEX,
body: {
'@timestamp': Date.now(),
diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts
index a8fb655b06d54..9dea7a3413f95 100644
--- a/x-pack/plugins/cases/public/common/translations.ts
+++ b/x-pack/plugins/cases/public/common/translations.ts
@@ -292,7 +292,8 @@ export const SELECT_CASE_TITLE = i18n.translate('xpack.cases.common.allCases.cas
export const MAX_LENGTH_ERROR = (field: string, length: number) =>
i18n.translate('xpack.cases.createCase.maxLengthError', {
values: { field, length },
- defaultMessage: 'The length of the {field} is too long. The maximum length is {length}.',
+ defaultMessage:
+ 'The length of the {field} is too long. The maximum length is {length} characters.',
});
export const MAX_TAGS_ERROR = (length: number) =>
diff --git a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx
index 54b2219745b1f..ac255f266620e 100644
--- a/x-pack/plugins/cases/public/components/add_comment/index.test.tsx
+++ b/x-pack/plugins/cases/public/components/add_comment/index.test.tsx
@@ -6,14 +6,14 @@
*/
import React from 'react';
-import { mount } from 'enzyme';
-import { waitFor, act, fireEvent } from '@testing-library/react';
+import { waitFor, act, fireEvent, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { noop } from 'lodash/fp';
import { noCreateCasesPermissions, TestProviders, createAppMockRenderer } from '../../common/mock';
import { CommentType } from '../../../common/api';
-import { SECURITY_SOLUTION_OWNER } from '../../../common/constants';
+import { SECURITY_SOLUTION_OWNER, MAX_COMMENT_LENGTH } from '../../../common/constants';
import { useCreateAttachments } from '../../containers/use_create_attachments';
import type { AddCommentProps, AddCommentRefObject } from '.';
import { AddComment } from '.';
@@ -52,8 +52,11 @@ const appId = 'testAppId';
const draftKey = `cases.${appId}.${addCommentProps.caseId}.${addCommentProps.id}.markdownEditor`;
describe('AddComment ', () => {
+ let appMockRender: AppMockRenderer;
+
beforeEach(() => {
jest.clearAllMocks();
+ appMockRender = createAppMockRenderer();
useCreateAttachmentsMock.mockImplementation(() => defaultResponse);
});
@@ -61,22 +64,47 @@ describe('AddComment ', () => {
sessionStorage.removeItem(draftKey);
});
- it('should post comment on submit click', async () => {
- const wrapper = mount(
-
-
+ it('renders correctly', () => {
+ appMockRender.render();
+
+ expect(screen.getByTestId('add-comment')).toBeInTheDocument();
+ });
+
+ it('should render spinner and disable submit when loading', () => {
+ useCreateAttachmentsMock.mockImplementation(() => ({
+ ...defaultResponse,
+ isLoading: true,
+ }));
+ appMockRender.render();
+
+ expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
+ expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
+ });
+
+ it('should hide the component when the user does not have create permissions', () => {
+ useCreateAttachmentsMock.mockImplementation(() => ({
+ ...defaultResponse,
+ isLoading: true,
+ }));
+
+ appMockRender.render(
+
+
);
- wrapper
- .find(`[data-test-subj="add-comment"] textarea`)
- .first()
- .simulate('change', { target: { value: sampleData.comment } });
+ expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
+ });
+
+ it('should post comment on submit click', async () => {
+ appMockRender.render();
+
+ const markdown = screen.getByTestId('euiMarkdownEditorTextArea');
- expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeTruthy();
- expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeFalsy();
+ userEvent.type(markdown, sampleData.comment);
+
+ userEvent.click(screen.getByTestId('submit-comment'));
- wrapper.find(`button[data-test-subj="submit-comment"]`).first().simulate('click');
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
expect(createAttachments).toBeCalledWith(
@@ -94,105 +122,49 @@ describe('AddComment ', () => {
});
await waitFor(() => {
- expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
+ expect(screen.getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent('');
});
});
- it('should render spinner and disable submit when loading', () => {
- useCreateAttachmentsMock.mockImplementation(() => ({
- ...defaultResponse,
- isLoading: true,
- }));
- const wrapper = mount(
-
-
-
- );
-
- expect(wrapper.find(`[data-test-subj="loading-spinner"]`).exists()).toBeTruthy();
- expect(
- wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
- ).toBeTruthy();
- });
-
- it('should disable submit button when isLoading is true', () => {
- useCreateAttachmentsMock.mockImplementation(() => ({
- ...defaultResponse,
- isLoading: true,
- }));
- const wrapper = mount(
-
-
-
- );
-
- expect(
- wrapper.find(`[data-test-subj="submit-comment"]`).first().prop('isDisabled')
- ).toBeTruthy();
- });
-
- it('should hide the component when the user does not have create permissions', () => {
- useCreateAttachmentsMock.mockImplementation(() => ({
- ...defaultResponse,
- isLoading: true,
- }));
- const wrapper = mount(
-
-
-
- );
-
- expect(wrapper.find(`[data-test-subj="add-comment"]`).exists()).toBeFalsy();
- });
-
it('should insert a quote', async () => {
const sampleQuote = 'what a cool quote \n with new lines';
const ref = React.createRef();
- const wrapper = mount(
-
-
-
- );
- wrapper
- .find(`[data-test-subj="add-comment"] textarea`)
- .first()
- .simulate('change', { target: { value: sampleData.comment } });
+ appMockRender.render();
+
+ userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), sampleData.comment);
await act(async () => {
ref.current!.addQuote(sampleQuote);
});
- expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe(
- `${sampleData.comment}\n\n> what a cool quote \n> with new lines \n\n`
- );
+ await waitFor(() => {
+ expect(screen.getByTestId('euiMarkdownEditorTextArea').textContent).toContain(
+ `${sampleData.comment}\n\n> what a cool quote \n> with new lines \n\n`
+ );
+ });
});
it('should call onFocus when adding a quote', async () => {
const ref = React.createRef();
- mount(
-
-
-
- );
+ appMockRender.render();
ref.current!.editor!.textarea!.focus = jest.fn();
+
await act(async () => {
ref.current!.addQuote('a comment');
});
- expect(ref.current!.editor!.textarea!.focus).toHaveBeenCalled();
+ await waitFor(() => {
+ expect(ref.current!.editor!.textarea!.focus).toHaveBeenCalled();
+ });
});
it('should NOT call onFocus on mount', async () => {
const ref = React.createRef();
- mount(
-
-
-
- );
+ appMockRender.render();
ref.current!.editor!.textarea!.focus = jest.fn();
expect(ref.current!.editor!.textarea!.focus).not.toHaveBeenCalled();
@@ -208,12 +180,10 @@ describe('AddComment ', () => {
const mockTimelineIntegration = { ...timelineIntegrationMock };
mockTimelineIntegration.hooks.useInsertTimeline = useInsertTimelineMock;
- const wrapper = mount(
-
-
-
-
-
+ appMockRender.render(
+
+
+
);
act(() => {
@@ -221,7 +191,56 @@ describe('AddComment ', () => {
});
await waitFor(() => {
- expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('[title](url)');
+ expect(screen.getByTestId('euiMarkdownEditorTextArea')).toHaveTextContent('[title](url)');
+ });
+ });
+
+ describe('errors', () => {
+ it('shows an error when comment is empty', async () => {
+ appMockRender.render();
+
+ const markdown = screen.getByTestId('euiMarkdownEditorTextArea');
+
+ userEvent.type(markdown, 'test');
+ userEvent.clear(markdown);
+
+ await waitFor(() => {
+ expect(screen.getByText('Empty comments are not allowed.')).toBeInTheDocument();
+ expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
+ });
+ });
+
+ it('shows an error when comment is of empty characters', async () => {
+ appMockRender.render();
+
+ const markdown = screen.getByTestId('euiMarkdownEditorTextArea');
+
+ userEvent.clear(markdown);
+ userEvent.type(markdown, ' ');
+
+ await waitFor(() => {
+ expect(screen.getByText('Empty comments are not allowed.')).toBeInTheDocument();
+ expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
+ });
+ });
+
+ it('shows an error when comment is too long', async () => {
+ const longComment = 'a'.repeat(MAX_COMMENT_LENGTH + 1);
+
+ appMockRender.render();
+
+ const markdown = screen.getByTestId('euiMarkdownEditorTextArea');
+
+ userEvent.paste(markdown, longComment);
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(
+ 'The length of the comment is too long. The maximum length is 30000 characters.'
+ )
+ ).toBeInTheDocument();
+ expect(screen.getByTestId('submit-comment')).toHaveAttribute('disabled');
+ });
});
});
});
@@ -247,9 +266,9 @@ describe('draft comment ', () => {
});
it('should clear session storage on submit', async () => {
- const result = appMockRenderer.render();
+ appMockRenderer.render();
- fireEvent.change(result.getByLabelText('caseComment'), {
+ fireEvent.change(screen.getByLabelText('caseComment'), {
target: { value: sampleData.comment },
});
@@ -258,10 +277,10 @@ describe('draft comment ', () => {
});
await waitFor(() => {
- expect(result.getByLabelText('caseComment')).toHaveValue(sessionStorage.getItem(draftKey));
+ expect(screen.getByLabelText('caseComment')).toHaveValue(sessionStorage.getItem(draftKey));
});
- fireEvent.click(result.getByTestId('submit-comment'));
+ fireEvent.click(screen.getByTestId('submit-comment'));
await waitFor(() => {
expect(onCommentSaving).toBeCalled();
@@ -280,7 +299,7 @@ describe('draft comment ', () => {
});
await waitFor(() => {
- expect(result.getByLabelText('caseComment').textContent).toBe('');
+ expect(screen.getByLabelText('caseComment').textContent).toBe('');
expect(sessionStorage.getItem(draftKey)).toBe('');
});
});
@@ -295,9 +314,9 @@ describe('draft comment ', () => {
});
it('should have draft comment same as existing session storage', async () => {
- const result = appMockRenderer.render();
+ appMockRenderer.render();
- expect(result.getByLabelText('caseComment')).toHaveValue('value set in storage');
+ expect(screen.getByLabelText('caseComment')).toHaveValue('value set in storage');
});
});
});
diff --git a/x-pack/plugins/cases/public/components/add_comment/index.tsx b/x-pack/plugins/cases/public/components/add_comment/index.tsx
index 94f45f129c0f3..59fc1fcde0fac 100644
--- a/x-pack/plugins/cases/public/components/add_comment/index.tsx
+++ b/x-pack/plugins/cases/public/components/add_comment/index.tsx
@@ -36,6 +36,7 @@ import type { AddCommentFormSchema } from './schema';
import { schema } from './schema';
import { InsertTimeline } from '../insert_timeline';
import { useCasesContext } from '../cases_context/use_cases_context';
+import { MAX_COMMENT_LENGTH } from '../../../common/constants';
const MySpinner = styled(EuiLoadingSpinner)`
position: absolute;
@@ -174,6 +175,9 @@ export const AddComment = React.memo(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusOnContext]);
+ const isDisabled =
+ isLoading || !comment?.trim().length || comment.trim().length > MAX_COMMENT_LENGTH;
+
return (