/* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ import './datapanel.scss'; import { uniq, groupBy } from 'lodash'; import React, { useState, memo, useCallback, useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiCallOut, EuiFormControlLayout, EuiSpacer, EuiFilterGroup, EuiFilterButton, EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { DataPublicPluginStart, EsQueryConfig, Query, Filter } from 'src/plugins/data/public'; import { htmlIdGenerator } from '@elastic/eui'; import { DatasourceDataPanelProps, DataType, StateSetter } from '../types'; import { ChildDragDropProvider, DragContextState } from '../drag_drop'; import { IndexPattern, IndexPatternPrivateState, IndexPatternField, IndexPatternRef, } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; import { syncExistingFields } from './loader'; import { fieldExists } from './pure_helpers'; import { Loader } from '../loader'; import { esQuery, IIndexPattern } from '../../../../../src/plugins/data/public'; export type Props = DatasourceDataPanelProps & { data: DataPublicPluginStart; changeIndexPattern: ( id: string, state: IndexPatternPrivateState, setState: StateSetter ) => void; charts: ChartsPluginSetup; }; import { LensFieldIcon } from './lens_field_icon'; import { ChangeIndexPattern } from './change_indexpattern'; import { ChartsPluginSetup } from '../../../../../src/plugins/charts/public'; import { FieldGroups, FieldList } from './field_list'; function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) { return fieldA.displayName.localeCompare(fieldB.displayName, undefined, { sensitivity: 'base' }); } const supportedFieldTypes = new Set(['string', 'number', 'boolean', 'date', 'ip', 'document']); const fieldTypeNames: Record = { document: i18n.translate('xpack.lens.datatypes.record', { defaultMessage: 'record' }), string: i18n.translate('xpack.lens.datatypes.string', { defaultMessage: 'string' }), number: i18n.translate('xpack.lens.datatypes.number', { defaultMessage: 'number' }), boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }), date: i18n.translate('xpack.lens.datatypes.date', { defaultMessage: 'date' }), ip: i18n.translate('xpack.lens.datatypes.ipAddress', { defaultMessage: 'IP' }), }; // Wrapper around esQuery.buildEsQuery, handling errors (e.g. because a query can't be parsed) by // returning a query dsl object not matching anything function buildSafeEsQuery( indexPattern: IIndexPattern, query: Query, filters: Filter[], queryConfig: EsQueryConfig ) { try { return esQuery.buildEsQuery(indexPattern, query, filters, queryConfig); } catch (e) { return { bool: { must_not: { match_all: {}, }, }, }; } } export function IndexPatternDataPanel({ setState, state, dragDropContext, core, data, query, filters, dateRange, changeIndexPattern, charts, showNoDataPopover, }: Props) { const { indexPatternRefs, indexPatterns, currentIndexPatternId } = state; const onChangeIndexPattern = useCallback( (id: string) => changeIndexPattern(id, state, setState), [state, setState, changeIndexPattern] ); const indexPatternList = uniq( Object.values(state.layers) .map((l) => l.indexPatternId) .concat(currentIndexPatternId) ) .sort((a, b) => a.localeCompare(b)) .filter((id) => !!indexPatterns[id]) .map((id) => ({ id, title: indexPatterns[id].title, timeFieldName: indexPatterns[id].timeFieldName, fields: indexPatterns[id].fields, hasRestrictions: indexPatterns[id].hasRestrictions, })); const dslQuery = buildSafeEsQuery( indexPatterns[currentIndexPatternId] as IIndexPattern, query, filters, esQuery.getEsQueryConfig(core.uiSettings) ); return ( <> syncExistingFields({ dateRange, setState, isFirstExistenceFetch: state.isFirstExistenceFetch, currentIndexPatternTitle: indexPatterns[currentIndexPatternId].title, showNoDataPopover, indexPatterns: indexPatternList, fetchJson: core.http.post, dslQuery, }) } loadDeps={[ query, filters, dateRange.fromDate, dateRange.toDate, indexPatternList.map((x) => `${x.title}:${x.timeFieldName}`).join(','), ]} /> {Object.keys(indexPatterns).length === 0 ? (

) : ( )} ); } interface DataPanelState { nameFilter: string; typeFilter: DataType[]; isTypeFilterOpen: boolean; isAvailableAccordionOpen: boolean; isEmptyAccordionOpen: boolean; isMetaAccordionOpen: boolean; } const defaultFieldGroups: { specialFields: IndexPatternField[]; availableFields: IndexPatternField[]; emptyFields: IndexPatternField[]; metaFields: IndexPatternField[]; } = { specialFields: [], availableFields: [], emptyFields: [], metaFields: [], }; const fieldFiltersLabel = i18n.translate('xpack.lens.indexPatterns.fieldFiltersLabel', { defaultMessage: 'Field filters', }); const htmlId = htmlIdGenerator('datapanel'); const fieldSearchDescriptionId = htmlId(); export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ currentIndexPatternId, indexPatternRefs, indexPatterns, existenceFetchFailed, query, dateRange, filters, dragDropContext, onChangeIndexPattern, core, data, existingFields, charts, }: Omit & { data: DataPublicPluginStart; currentIndexPatternId: string; indexPatternRefs: IndexPatternRef[]; indexPatterns: Record; dragDropContext: DragContextState; onChangeIndexPattern: (newId: string) => void; existingFields: IndexPatternPrivateState['existingFields']; charts: ChartsPluginSetup; existenceFetchFailed?: boolean; }) { const [localState, setLocalState] = useState({ nameFilter: '', typeFilter: [], isTypeFilterOpen: false, isAvailableAccordionOpen: true, isEmptyAccordionOpen: false, isMetaAccordionOpen: false, }); const currentIndexPattern = indexPatterns[currentIndexPatternId]; const allFields = currentIndexPattern.fields; const clearLocalState = () => setLocalState((s) => ({ ...s, nameFilter: '', typeFilter: [] })); const hasSyncedExistingFields = existingFields[currentIndexPattern.title]; const availableFieldTypes = uniq(allFields.map(({ type }) => type)).filter( (type) => type in fieldTypeNames ); const fieldInfoUnavailable = existenceFetchFailed || currentIndexPattern.hasRestrictions; const unfilteredFieldGroups: FieldGroups = useMemo(() => { const containsData = (field: IndexPatternField) => { const overallField = currentIndexPattern.getFieldByName(field.name); return ( overallField && fieldExists(existingFields, currentIndexPattern.title, overallField.name) ); }; const allSupportedTypesFields = allFields.filter((field) => supportedFieldTypes.has(field.type) ); const sorted = allSupportedTypesFields.sort(sortFields); let groupedFields; // optimization before existingFields are synced if (!hasSyncedExistingFields) { groupedFields = { ...defaultFieldGroups, ...groupBy(sorted, (field) => { if (field.type === 'document') { return 'specialFields'; } else if (field.meta) { return 'metaFields'; } else { return 'emptyFields'; } }), }; } groupedFields = { ...defaultFieldGroups, ...groupBy(sorted, (field) => { if (field.type === 'document') { return 'specialFields'; } else if (field.meta) { return 'metaFields'; } else if (containsData(field)) { return 'availableFields'; } else return 'emptyFields'; }), }; const fieldGroupDefinitions: FieldGroups = { SpecialFields: { fields: groupedFields.specialFields, fieldCount: 1, isAffectedByGlobalFilter: false, isAffectedByTimeFilter: false, isInitiallyOpen: false, showInAccordion: false, title: '', hideDetails: true, }, AvailableFields: { fields: groupedFields.availableFields, fieldCount: groupedFields.availableFields.length, isInitiallyOpen: true, showInAccordion: true, title: fieldInfoUnavailable ? i18n.translate('xpack.lens.indexPattern.allFieldsLabel', { defaultMessage: 'All fields', }) : i18n.translate('xpack.lens.indexPattern.availableFieldsLabel', { defaultMessage: 'Available fields', }), helpText: i18n.translate('xpack.lens.indexPattern.allFieldsLabelHelp', { defaultMessage: 'Available fields have data in the first 500 documents that match your filters. To view all fields, expand Empty fields. Some field types cannot be visualized in Lens, including full text and geographic fields.', }), isAffectedByGlobalFilter: !!filters.length, isAffectedByTimeFilter: true, hideDetails: fieldInfoUnavailable, defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noAvailableDataLabel', { defaultMessage: `There are no available fields that contain data.`, }), }, EmptyFields: { fields: groupedFields.emptyFields, fieldCount: groupedFields.emptyFields.length, isAffectedByGlobalFilter: false, isAffectedByTimeFilter: false, isInitiallyOpen: false, showInAccordion: true, hideDetails: false, title: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabel', { defaultMessage: 'Empty fields', }), defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noEmptyDataLabel', { defaultMessage: `There are no empty fields.`, }), helpText: i18n.translate('xpack.lens.indexPattern.emptyFieldsLabelHelp', { defaultMessage: 'Empty fields did not contain any values in the first 500 documents based on your filters.', }), }, MetaFields: { fields: groupedFields.metaFields, fieldCount: groupedFields.metaFields.length, isAffectedByGlobalFilter: false, isAffectedByTimeFilter: false, isInitiallyOpen: false, showInAccordion: true, hideDetails: false, title: i18n.translate('xpack.lens.indexPattern.metaFieldsLabel', { defaultMessage: 'Meta fields', }), defaultNoFieldsMessage: i18n.translate('xpack.lens.indexPatterns.noMetaDataLabel', { defaultMessage: `There are no meta fields.`, }), }, }; // do not show empty field accordion if there is no existence information if (fieldInfoUnavailable) { delete fieldGroupDefinitions.EmptyFields; } return fieldGroupDefinitions; }, [ allFields, existingFields, currentIndexPattern, hasSyncedExistingFields, fieldInfoUnavailable, filters.length, ]); const fieldGroups: FieldGroups = useMemo(() => { const filterFieldGroup = (fieldGroup: IndexPatternField[]) => fieldGroup.filter((field) => { if ( localState.nameFilter.length && !field.name.toLowerCase().includes(localState.nameFilter.toLowerCase()) && !field.displayName.toLowerCase().includes(localState.nameFilter.toLowerCase()) ) { return false; } if (localState.typeFilter.length > 0) { return localState.typeFilter.includes(field.type as DataType); } return true; }); return Object.fromEntries( Object.entries(unfilteredFieldGroups).map(([name, group]) => [ name, { ...group, fields: filterFieldGroup(group.fields) }, ]) ); }, [unfilteredFieldGroups, localState.nameFilter, localState.typeFilter]); const fieldProps = useMemo( () => ({ core, data, indexPattern: currentIndexPattern, highlight: localState.nameFilter.toLowerCase(), dateRange, query, filters, chartsThemeService: charts.theme, }), [ core, data, currentIndexPattern, dateRange, query, filters, localState.nameFilter, charts.theme, ] ); return (
{ onChangeIndexPattern(newId); clearLocalState(); }} />
{ trackUiEvent('indexpattern_filters_cleared'); clearLocalState(); }, }} > { setLocalState({ ...localState, nameFilter: e.target.value }); }} aria-label={i18n.translate('xpack.lens.indexPatterns.filterByNameAriaLabel', { defaultMessage: 'Search fields', })} aria-describedby={fieldSearchDescriptionId} /> setLocalState(() => ({ ...localState, isTypeFilterOpen: false }))} button={ { setLocalState((s) => ({ ...s, isTypeFilterOpen: !localState.isTypeFilterOpen, })); }} > {fieldFiltersLabel} } > ( { trackUiEvent('indexpattern_type_filter_toggled'); setLocalState((s) => ({ ...s, typeFilter: localState.typeFilter.includes(type) ? localState.typeFilter.filter((t) => t !== type) : [...localState.typeFilter, type], })); }} > {fieldTypeNames[type]} ))} />
{i18n.translate('xpack.lens.indexPatterns.fieldSearchLiveRegion', { defaultMessage: '{availableFields} available {availableFields, plural, one {field} other {fields}}. {emptyFields} empty {emptyFields, plural, one {field} other {fields}}. {metaFields} meta {metaFields, plural, one {field} other {fields}}.', values: { availableFields: fieldGroups.AvailableFields.fields.length, // empty fields can be undefined if there is no existence information to be fetched emptyFields: fieldGroups.EmptyFields?.fields.length || 0, metaFields: fieldGroups.MetaFields.fields.length, }, })}
field.type === 'document' || fieldExists(existingFields, currentIndexPattern.title, field.name) } fieldProps={fieldProps} fieldGroups={fieldGroups} hasSyncedExistingFields={!!hasSyncedExistingFields} filter={{ nameFilter: localState.nameFilter, typeFilter: localState.typeFilter, }} currentIndexPatternId={currentIndexPatternId} existenceFetchFailed={existenceFetchFailed} existFieldsInIndex={!!allFields.length} />
); }; export const MemoizedDataPanel = memo(InnerIndexPatternDataPanel);