[ML] Add Field statistics embeddable as panel in Dashboard (#184030)

## Summary

This PR adds Field statistics embeddable as panel in Dashboard 

By default, it will enable the ES|QL editor for the field stats panel.
It will allow for editing of the ES|QL query, and time range.




4b5438c7-051f-4627-aab1-b802c23ca652



e9bae0e4-17cf-4a86-ad70-0da9d3667b53




If and only if ES|QL is disabled, it will show the data view picker as a
fallback.





### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Quynh Nguyen (Quinn) 2024-07-01 23:09:44 -05:00 committed by GitHub
parent d5a91fcc5d
commit de027b80b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1534 additions and 95 deletions

View file

@ -20,6 +20,7 @@ import {
SmartFieldFallbackTooltip,
} from '@kbn/unified-field-list';
import type { DataVisualizerTableItem } from '@kbn/data-visualizer-plugin/public/application/common/components/stats_table/types';
import { isOfAggregateQueryType } from '@kbn/es-query';
import { useDiscoverServices } from '../../../../hooks/use_discover_services';
import { FIELD_STATISTICS_LOADED } from './constants';
@ -51,6 +52,7 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps
trackUiMetric,
searchSessionId,
additionalFieldGroups,
timeRange,
} = props;
const visibleFields = useMemo(
@ -156,6 +158,7 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps
dataView={dataView}
savedSearch={savedSearch}
filters={filters}
esqlQuery={isEsqlMode && isOfAggregateQueryType(query) ? query : undefined}
query={query}
visibleFieldNames={visibleFields}
sessionId={searchSessionId}
@ -166,8 +169,9 @@ export const FieldStatisticsTable = React.memo((props: FieldStatisticsTableProps
showPreviewByDefault={showPreviewByDefault}
onTableUpdate={updateState}
renderFieldName={renderFieldName}
esql={isEsqlMode}
isEsqlMode={isEsqlMode}
overridableServices={overridableServices}
timeRange={timeRange}
/>
</EuiFlexItem>
);

View file

@ -8,8 +8,12 @@
import type { DataViewField, DataView } from '@kbn/data-views-plugin/public';
import { type UiCounterMetricType } from '@kbn/analytics';
import type { Filter, Query, AggregateQuery } from '@kbn/es-query';
import type { SerializedTitles } from '@kbn/presentation-publishing';
import type { Filter, Query, AggregateQuery, TimeRange } from '@kbn/es-query';
import type {
PublishesBlockingError,
PublishesDataLoading,
SerializedTitles,
} from '@kbn/presentation-publishing';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { type BehaviorSubject } from 'rxjs';
import { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
@ -116,7 +120,9 @@ interface FieldStatisticsTableEmbeddableComponentApi {
export type FieldStatisticsTableEmbeddableApi =
DefaultEmbeddableApi<FieldStatisticsTableEmbeddableState> &
FieldStatisticsTableEmbeddableComponentApi;
FieldStatisticsTableEmbeddableComponentApi &
PublishesDataLoading &
PublishesBlockingError;
export interface FieldStatisticsTableProps {
/**
@ -173,4 +179,8 @@ export interface FieldStatisticsTableProps {
* If table should query using ES|QL
*/
isEsqlMode?: boolean;
/**
* Time range
*/
timeRange?: TimeRange;
}

View file

@ -664,6 +664,7 @@ export class SavedSearchEmbeddable
isEsqlMode,
});
const timeRange = this.getTimeRange();
if (
this.services.uiSettings.get(SHOW_FIELD_STATISTICS) === true &&
viewMode === VIEW_MODE.AGGREGATED_LEVEL &&
@ -683,6 +684,7 @@ export class SavedSearchEmbeddable
onAddFilter={searchProps.onFilter}
searchSessionId={this.input.searchSessionId}
isEsqlMode={isEsqlMode}
timeRange={timeRange}
/>
</KibanaContextProvider>
</KibanaRenderContextProvider>,

View file

@ -23,9 +23,10 @@ import { isFilterBasedDefaultQuery } from './filter_based_default_query';
*/
export function buildBaseFilterCriteria(
timeFieldName?: string,
earliestMs?: number,
latestMs?: number,
query?: Query['query']
earliestMs?: number | string,
latestMs?: number | string,
query?: Query['query'],
timeFormat = 'epoch_millis'
): estypes.QueryDslQueryContainer[] {
const filterCriteria = [];
@ -35,7 +36,7 @@ export function buildBaseFilterCriteria(
[timeFieldName]: {
gte: earliestMs,
lte: latestMs,
format: 'epoch_millis',
format: timeFormat,
},
},
});

View file

@ -211,8 +211,8 @@ export function isValidFieldStats(arg: unknown): arg is FieldStats {
export interface FieldStatsCommonRequestParams {
index: string;
timeFieldName?: string;
earliestMs?: number | undefined;
latestMs?: number | undefined;
earliestMs?: number | string | undefined;
latestMs?: number | string | undefined;
runtimeFieldMap?: estypes.MappingRuntimeFields;
intervalMs?: number;
query: estypes.QueryDslQueryContainer;
@ -227,8 +227,8 @@ export type SupportedAggs = Set<string>;
export interface OverallStatsSearchStrategyParams {
sessionId?: string;
earliest?: number;
latest?: number;
earliest?: number | string;
latest?: number | string;
aggInterval: TimeBucketsInterval;
intervalMs?: number;
searchQuery: Query['query'];

View file

@ -0,0 +1,24 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { Query } from '@kbn/es-query';
import { buildBaseFilterCriteria } from '@kbn/ml-query-utils';
export const buildFilterCriteria = (
timeFieldName?: string,
earliestMs?: number | string,
latestMs?: number | string,
query?: Query['query']
): estypes.QueryDslQueryContainer[] => {
return buildBaseFilterCriteria(
timeFieldName,
earliestMs,
latestMs,
query,
'epoch_millis||strict_date_optional_time'
);
};

View file

@ -29,6 +29,7 @@
"cloud"
],
"requiredBundles": [
"dataViews",
"kibanaReact",
"kibanaUtils",
"maps",
@ -37,6 +38,7 @@
"uiActions",
"lens",
"textBasedLanguages",
"visualizations"
]
}
}

View file

@ -16,8 +16,8 @@ import { createRandomSamplerWrapper } from '@kbn/ml-random-sampler-utils';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { each, get } from 'lodash';
import { lastValueFrom } from 'rxjs';
import { buildBaseFilterCriteria } from '@kbn/ml-query-utils';
import useObservable from 'react-use/lib/useObservable';
import { buildFilterCriteria } from '../../../../common/utils/build_query_filters';
import { useDataVisualizerKibana } from '../../kibana_context';
import { displayError } from '../util/display_error';
@ -70,7 +70,7 @@ export const getDocumentCountStatsRequest = (
} = params;
const size = 0;
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery);
const rawAggs: Record<string, estypes.AggregationsAggregationContainer> = {
eventRate: {

View file

@ -20,6 +20,7 @@ import type { LensPublicStart } from '@kbn/lens-plugin/public';
import type { IndexPatternFieldEditorStart } from '@kbn/data-view-field-editor-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
export interface DataVisualizerSetupDependencies {
home?: HomePublicPluginSetup;
@ -42,4 +43,5 @@ export interface DataVisualizerStartDependencies {
uiActions?: UiActionsStart;
cloud?: CloudStart;
savedSearch: SavedSearchPublicPluginStart;
usageCollection?: UsageCollectionStart;
}

View file

@ -73,7 +73,7 @@ import {
RANDOM_SAMPLER_OPTION,
type RandomSamplerOption,
} from '../../constants/random_sampler';
import type { FieldStatisticsTableEmbeddableState } from '../../embeddables/grid_embeddable/types';
import type { FieldStatisticTableEmbeddableProps } from '../../embeddables/grid_embeddable/types';
const defaultSearchQuery = {
match_all: {},
@ -217,7 +217,7 @@ export const IndexDataVisualizerView: FC<IndexDataVisualizerViewProps> = (dataVi
});
};
const input: Required<FieldStatisticsTableEmbeddableState, 'dataView'> = useMemo(() => {
const input: Required<FieldStatisticTableEmbeddableProps, 'dataView'> = useMemo(() => {
return {
dataView: currentDataView,
savedSearch: currentSavedSearch,

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const FIELD_STATS_EMBEDDABLE_TYPE = 'field_stats_table';
export const FIELD_STATS_DATA_VIEW_REF_NAME = 'fieldStatsTableDataViewId';

View file

@ -0,0 +1,62 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useRef, useState, useCallback } from 'react';
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
import { EuiFlexItem } from '@elastic/eui';
import type { AggregateQuery } from '@kbn/es-query';
const expandCodeEditor = (status: boolean) => {};
interface FieldStatsESQLEditorProps {
canEditTextBasedQuery?: boolean;
query: AggregateQuery;
setQuery: (query: AggregateQuery) => void;
onQuerySubmit: (query: AggregateQuery, abortController: AbortController) => Promise<void>;
}
export const FieldStatsESQLEditor = ({
canEditTextBasedQuery = true,
query,
setQuery,
onQuerySubmit,
}: FieldStatsESQLEditorProps) => {
const prevQuery = useRef<AggregateQuery>(query);
const [isVisualizationLoading, setIsVisualizationLoading] = useState(false);
const onTextLangQuerySubmit = useCallback(
async (q, abortController) => {
if (q && onQuerySubmit) {
setIsVisualizationLoading(true);
await onQuerySubmit(q, abortController);
setIsVisualizationLoading(false);
}
},
[onQuerySubmit]
);
if (!canEditTextBasedQuery) return null;
return (
<EuiFlexItem grow={false} data-test-subj="InlineEditingESQLEditor">
<TextBasedLangEditor
query={query}
onTextLangQueryChange={(q) => {
setQuery(q);
prevQuery.current = q;
}}
expandCodeEditor={expandCodeEditor}
isCodeEditorExpanded
hideMinimizeButton
editorIsInline
hideRunQueryText
onTextLangQuerySubmit={onTextLangQuerySubmit}
isDisabled={false}
allowQueryCancellation
isLoading={isVisualizationLoading}
/>
</EuiFlexItem>
);
};

View file

@ -0,0 +1,392 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Reference } from '@kbn/content-management-utils';
import type { StartServicesAccessor } from '@kbn/core-lifecycle-browser';
import {
APPLY_FILTER_TRIGGER,
generateFilters,
type DataPublicPluginStart,
} from '@kbn/data-plugin/public';
import type { DataViewField } from '@kbn/data-views-plugin/common';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/common';
import type { ReactEmbeddableFactory } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import {
apiHasExecutionContext,
fetch$,
initializeTimeRange,
initializeTitles,
useBatchedPublishingSubjects,
useFetchContext,
} from '@kbn/presentation-publishing';
import { cloneDeep } from 'lodash';
import React, { useEffect } from 'react';
import useObservable from 'react-use/lib/useObservable';
import {
BehaviorSubject,
map,
skipWhile,
Subscription,
skip,
switchMap,
distinctUntilChanged,
} from 'rxjs';
import type { DataView } from '@kbn/data-views-plugin/public';
import { dynamic } from '@kbn/shared-ux-utility';
import { isDefined } from '@kbn/ml-is-defined';
import { EuiFlexItem } from '@elastic/eui';
import { css } from '@emotion/react';
import type { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import type { Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
import { getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import { ACTION_GLOBAL_APPLY_FILTER } from '@kbn/unified-search-plugin/public';
import type { DataVisualizerTableState } from '../../../../../common/types';
import type { DataVisualizerPluginStart } from '../../../../plugin';
import type { FieldStatisticsTableEmbeddableState } from '../grid_embeddable/types';
import { FieldStatsInitializerViewType } from '../grid_embeddable/types';
import { FIELD_STATS_EMBEDDABLE_TYPE, FIELD_STATS_DATA_VIEW_REF_NAME } from './constants';
import { initializeFieldStatsControls } from './initialize_field_stats_controls';
import type { DataVisualizerStartDependencies } from '../../../common/types/data_visualizer_plugin';
import type { FieldStatisticsTableEmbeddableApi } from './types';
import { isESQLQuery } from '../../search_strategy/requests/esql_utils';
export interface EmbeddableFieldStatsChartStartServices {
data: DataPublicPluginStart;
}
export type EmbeddableFieldStatsChartType = typeof FIELD_STATS_EMBEDDABLE_TYPE;
const FieldStatisticsWrapper = dynamic(() => import('../grid_embeddable/field_stats_wrapper'));
const ERROR_MSG = {
APPLY_FILTER_ERR: i18n.translate('xpack.dataVisualizer.fieldStats.errors.errorApplyingFilter', {
defaultMessage: 'Error applying filter',
}),
UPDATE_CONFIG_ERROR: i18n.translate(
'xpack.dataVisualizer.fieldStats.errors.errorUpdatingConfig',
{
defaultMessage: 'Error updating settings for field statistics.',
}
),
};
export const getDependencies = async (
getStartServices: StartServicesAccessor<
DataVisualizerStartDependencies,
DataVisualizerPluginStart
>
) => {
const [
{ http, uiSettings, notifications, ...startServices },
{ lens, data, usageCollection, fieldFormats },
] = await getStartServices();
return {
http,
uiSettings,
data,
notifications,
lens,
usageCollection,
fieldFormats,
...startServices,
};
};
export const getFieldStatsChartEmbeddableFactory = (
getStartServices: StartServicesAccessor<
DataVisualizerStartDependencies,
DataVisualizerPluginStart
>
) => {
const factory: ReactEmbeddableFactory<
FieldStatisticsTableEmbeddableState,
FieldStatisticsTableEmbeddableState,
FieldStatisticsTableEmbeddableApi
> = {
type: FIELD_STATS_EMBEDDABLE_TYPE,
deserializeState: (state) => {
const serializedState = cloneDeep(state.rawState);
// inject the reference
const dataViewIdRef = state.references?.find(
(ref) => ref.name === FIELD_STATS_DATA_VIEW_REF_NAME
);
// if the serializedState already contains a dataViewId, we don't want to overwrite it. (Unsaved state can cause this)
if (dataViewIdRef && serializedState && !serializedState.dataViewId) {
serializedState.dataViewId = dataViewIdRef?.id;
}
return serializedState;
},
buildEmbeddable: async (state, buildApi, uuid, parentApi) => {
const [coreStart, pluginStart] = await getStartServices();
const { http, uiSettings, notifications, ...startServices } = coreStart;
const { lens, data, usageCollection, fieldFormats } = pluginStart;
const deps = {
http,
uiSettings,
data,
notifications,
lens,
usageCollection,
fieldFormats,
...startServices,
};
const {
api: timeRangeApi,
comparators: timeRangeComparators,
serialize: serializeTimeRange,
} = initializeTimeRange(state);
const { titlesApi, titleComparators, serializeTitles } = initializeTitles(state);
const {
fieldStatsControlsApi,
dataLoadingApi,
fieldStatsControlsComparators,
serializeFieldStatsChartState,
onFieldStatsTableDestroy,
resetData$,
} = initializeFieldStatsControls(state);
const { onError, dataLoading, blockingError } = dataLoadingApi;
const defaultDataViewId = await deps.data.dataViews.getDefaultId();
const validDataViewId: string =
isDefined(state.dataViewId) && state.dataViewId !== ''
? state.dataViewId
: defaultDataViewId ?? '';
let initialDataView: DataView[] | undefined;
try {
const dataView = isESQLQuery(state.query)
? await getESQLAdHocDataview(
getIndexPatternFromESQLQuery(state.query.esql),
deps.data.dataViews
)
: await deps.data.dataViews.get(validDataViewId);
initialDataView = [dataView];
} catch (error) {
// Only need to publish blocking error if viewtype is data view, and no data view found
if (state.viewType === FieldStatsInitializerViewType.DATA_VIEW) {
onError(error);
}
}
const dataViews$ = new BehaviorSubject<DataView[] | undefined>(initialDataView);
const subscriptions = new Subscription();
if (fieldStatsControlsApi.dataViewId$) {
subscriptions.add(
fieldStatsControlsApi.dataViewId$
.pipe(
skip(1),
skipWhile((dataViewId) => !dataViewId && !defaultDataViewId),
switchMap(async (dataViewId) => {
try {
return await deps.data.dataViews.get(dataViewId ?? defaultDataViewId);
} catch (error) {
return undefined;
}
})
)
.subscribe((nextSelectedDataView) => {
if (nextSelectedDataView) {
dataViews$.next([nextSelectedDataView]);
}
})
);
}
const { toasts } = deps.notifications;
const api = buildApi(
{
...timeRangeApi,
...titlesApi,
...fieldStatsControlsApi,
// PublishesDataLoading
dataLoading,
// PublishesBlockingError
blockingError,
getTypeDisplayName: () =>
i18n.translate('xpack.dataVisualizer.fieldStats.typeDisplayName', {
defaultMessage: 'field statistics',
}),
isEditingEnabled: () => true,
onEdit: async () => {
try {
const { resolveEmbeddableFieldStatsUserInput } = await import(
'./resolve_field_stats_embeddable_input'
);
const chartState = serializeFieldStatsChartState();
const nextUpdate = await resolveEmbeddableFieldStatsUserInput(
coreStart,
pluginStart,
parentApi,
uuid,
false,
chartState,
fieldStatsControlsApi
);
fieldStatsControlsApi.updateUserInput(nextUpdate);
} catch (e) {
toasts.addError(e, { title: ERROR_MSG.UPDATE_CONFIG_ERROR });
}
},
dataViews: dataViews$,
serializeState: () => {
const dataViewId = fieldStatsControlsApi.dataViewId$?.getValue();
const references: Reference[] = dataViewId
? [
{
type: DATA_VIEW_SAVED_OBJECT_TYPE,
name: FIELD_STATS_DATA_VIEW_REF_NAME,
id: dataViewId,
},
]
: [];
return {
rawState: {
...serializeTitles(),
...serializeTimeRange(),
...serializeFieldStatsChartState(),
},
references,
};
},
},
{
...timeRangeComparators,
...titleComparators,
...fieldStatsControlsComparators,
}
);
const reload$ = fetch$(api).pipe(
skipWhile((fetchContext) => !fetchContext.isReload),
map(() => Date.now())
);
const reset$ = resetData$.pipe(skip(1), distinctUntilChanged());
const onTableUpdate = (changes: Partial<DataVisualizerTableState>) => {
if (isDefined(changes?.showDistributions)) {
fieldStatsControlsApi.showDistributions$.next(changes.showDistributions);
}
};
const addFilters = (filters: Filter[], actionId: string = ACTION_GLOBAL_APPLY_FILTER) => {
if (!pluginStart.uiActions) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);
return;
}
const trigger = pluginStart.uiActions.getTrigger(APPLY_FILTER_TRIGGER);
if (!trigger) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);
return;
}
const actionContext = {
embeddable: api,
trigger,
} as ActionExecutionContext;
const executeContext = {
...actionContext,
filters,
};
try {
const action = pluginStart.uiActions.getAction(actionId);
action.execute(executeContext);
} catch (error) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);
}
};
const statsTableCss = css({
width: '100%',
height: '100%',
overflowY: 'auto',
});
return {
api,
Component: () => {
if (!apiHasExecutionContext(parentApi)) {
onError(new Error('Parent API does not have execution context'));
}
const { filters: globalFilters, query: globalQuery, timeRange } = useFetchContext(api);
const [dataViews, esqlQuery, viewType, showPreviewByDefault] =
useBatchedPublishingSubjects(
api.dataViews,
api.query$,
api.viewType$,
api.showDistributions$
);
const lastReloadRequestTime = useObservable(reload$, Date.now());
const isEsqlMode = viewType === FieldStatsInitializerViewType.ESQL;
const dataView =
Array.isArray(dataViews) && dataViews.length > 0 ? dataViews[0] : undefined;
const onAddFilter = (
field: string | DataViewField,
value: string,
operator: '+' | '-'
) => {
if (!dataView || !pluginStart.data) {
toasts.addWarning(ERROR_MSG.APPLY_FILTER_ERR);
return;
}
let filters = generateFilters(
pluginStart.data.query.filterManager,
field,
value,
operator,
dataView
);
filters = filters.map((filter) => ({
...filter,
$state: { store: FilterStateStore.APP_STATE },
}));
addFilters(filters);
};
// On destroy
useEffect(() => {
return () => {
subscriptions?.unsubscribe();
onFieldStatsTableDestroy();
};
}, []);
return (
<EuiFlexItem css={statsTableCss} data-test-subj="dashboardFieldStatsEmbeddedContent">
<FieldStatisticsWrapper
shouldGetSubfields={false}
dataView={dataView}
esqlQuery={esqlQuery}
query={globalQuery}
filters={globalFilters}
lastReloadRequestTime={lastReloadRequestTime}
isEsqlMode={isEsqlMode}
onTableUpdate={onTableUpdate}
showPreviewByDefault={showPreviewByDefault}
onAddFilter={onAddFilter}
resetData$={reset$}
timeRange={timeRange}
onRenderComplete={dataLoadingApi.onRenderComplete}
/>
</EuiFlexItem>
);
},
};
},
};
return factory;
};

View file

@ -0,0 +1,301 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
EuiButton,
EuiButtonEmpty,
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
EuiTitle,
EuiSpacer,
EuiIconTip,
EuiCallOut,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { FC } from 'react';
import { useEffect } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import { ENABLE_ESQL, getESQLAdHocDataview, getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import type { AggregateQuery } from '@kbn/es-query';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { FieldStatsESQLEditor } from './field_stats_esql_editor';
import type {
FieldStatisticsTableEmbeddableState,
FieldStatsInitialState,
} from '../grid_embeddable/types';
import { FieldStatsInitializerViewType } from '../grid_embeddable/types';
import { isESQLQuery } from '../../search_strategy/requests/esql_utils';
import { DataSourceTypeSelector } from './field_stats_initializer_view_type';
export interface FieldStatsInitializerProps {
initialInput?: Partial<FieldStatisticsTableEmbeddableState>;
onCreate: (props: FieldStatsInitialState) => Promise<void>;
onCancel: () => void;
onPreview: (update: Partial<FieldStatsInitialState>) => Promise<void>;
isNewPanel: boolean;
}
const defaultESQLQuery = { esql: '' };
const defaultTitle = i18n.translate('xpack.dataVisualizer.fieldStatistics.displayName', {
defaultMessage: 'Field statistics',
});
const isScrollable = false;
export const FieldStatisticsInitializer: FC<FieldStatsInitializerProps> = ({
initialInput,
onCreate,
onCancel,
onPreview,
isNewPanel,
}) => {
const {
data: { dataViews },
unifiedSearch: {
ui: { IndexPatternSelect },
},
uiSettings,
} = useDataVisualizerKibana().services;
const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? '');
const [viewType, setViewType] = useState(
initialInput?.viewType ?? FieldStatsInitializerViewType.DATA_VIEW
);
const [esqlQuery, setQuery] = useState<AggregateQuery>(initialInput?.query ?? defaultESQLQuery);
const isEsqlEnabled = useMemo(() => uiSettings.get(ENABLE_ESQL), [uiSettings]);
useEffect(() => {
if (initialInput?.viewType === undefined) {
// By default, if ES|QL is enabled, then use ES|QL
setViewType(
isEsqlEnabled ? FieldStatsInitializerViewType.ESQL : FieldStatsInitializerViewType.DATA_VIEW
);
}
}, [isEsqlEnabled, initialInput?.viewType]);
const isEsqlMode = viewType === FieldStatsInitializerViewType.ESQL;
const updatedProps = useMemo(() => {
return {
viewType,
title: initialInput?.title ?? defaultTitle,
dataViewId,
query: isEsqlMode ? esqlQuery : undefined,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataViewId, viewType, esqlQuery.esql, isEsqlMode]);
const onESQLQuerySubmit = useCallback(
async (query: AggregateQuery, abortController: AbortController) => {
const adhocDataView = await getESQLAdHocDataview(
getIndexPatternFromESQLQuery(query.esql),
dataViews
);
if (adhocDataView && adhocDataView.id) {
setDataViewId(adhocDataView.id);
}
await onPreview({
viewType,
dataViewId: adhocDataView?.id,
query,
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isEsqlMode]
);
const isEsqlFormValid = isEsqlMode ? isEsqlEnabled && isESQLQuery(esqlQuery) : true;
const isDataViewFormValid =
viewType === FieldStatsInitializerViewType.DATA_VIEW ? dataViewId !== '' : true;
return (
<>
<EuiFlyoutHeader
hasBorder={true}
css={css`
pointer-events: auto;
background-color: ${euiThemeVars.euiColorEmptyShade};
`}
data-test-subj="editFlyoutHeader"
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiTitle size="xs" data-test-subj="inlineEditingFlyoutLabel">
<h2>
{isNewPanel
? i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.createLable',
{
defaultMessage: 'Create field statistics',
}
)
: i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.editLabel',
{
defaultMessage: 'Edit field statistics',
}
)}{' '}
<EuiIconTip
type="iInCircle"
content={i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.samplingTooltip',
{
defaultMessage:
'Field statistics uses the random sampling aggregation to increase performance, but some accuracy might be lost.',
}
)}
/>
</h2>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutHeader>
<EuiFlyoutBody
className="lnsEditFlyoutBody"
css={css`
// styles needed to display extra drop targets that are outside of the config panel main area
overflow-y: auto;
padding-left: ${euiThemeVars.euiFormMaxWidth};
margin-left: -${euiThemeVars.euiFormMaxWidth};
pointer-events: none;
.euiFlyoutBody__overflow {
-webkit-mask-image: none;
padding-left: inherit;
margin-left: inherit;
${!isScrollable &&
`
overflow-y: hidden;
`}
> * {
pointer-events: auto;
}
}
.euiFlyoutBody__overflowContent {
padding: 0;
block-size: 100%;
}
border-bottom: 2px solid ${euiThemeVars.euiBorderColor};
`}
>
<EuiFlexGroup
css={css`
block-size: 100%;
`}
direction="column"
gutterSize="none"
>
{isNewPanel ? (
<EuiCallOut
size="s"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.description"
defaultMessage="The visualization displays summarized information and statistics to show how each field in your data is populated."
/>
}
/>
) : null}
{initialInput?.viewType === FieldStatsInitializerViewType.ESQL && !isEsqlEnabled ? (
<>
<DataSourceTypeSelector value={viewType} onChange={setViewType} />
</>
) : null}
{viewType === FieldStatsInitializerViewType.ESQL && !isEsqlEnabled ? (
<>
<EuiSpacer size="m" />
<EuiCodeBlock>{esqlQuery.esql}</EuiCodeBlock>
</>
) : null}
{viewType === FieldStatsInitializerViewType.DATA_VIEW ? (
<EuiFormRow
fullWidth
label={i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.dataViewLabel',
{
defaultMessage: 'Data view',
}
)}
>
<IndexPatternSelect
autoFocus={!dataViewId}
fullWidth
compressed
indexPatternId={dataViewId}
placeholder={i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.dataViewSelectorPlaceholder',
{
defaultMessage: 'Select data view',
}
)}
onChange={(newId) => {
setDataViewId(newId ?? '');
}}
/>
</EuiFormRow>
) : null}
{isEsqlMode && isEsqlEnabled ? (
<FieldStatsESQLEditor
query={esqlQuery}
setQuery={setQuery}
onQuerySubmit={onESQLQuerySubmit}
/>
) : null}
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onCancel}
data-test-subj="fieldStatsInitializerCancelButton"
flush="left"
aria-label={i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.cancelButtonAriaLabel',
{
defaultMessage: 'Cancel applied changes',
}
)}
>
<FormattedMessage
id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
onClick={onCreate.bind(null, updatedProps)}
fill
aria-label={i18n.translate(
'xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.applyFlyoutAriaLabel',
{
defaultMessage: 'Apply changes',
}
)}
disabled={!isEsqlFormValid || !isDataViewFormValid}
iconType="check"
data-test-subj="applyFlyoutButton"
>
<FormattedMessage
id="xpack.dataVisualizer.fieldStatisticsDashboardPanel.config.applyAndCloseLabel"
defaultMessage="Apply and close"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
};

View file

@ -0,0 +1,69 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FC } from 'react';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonGroup, EuiFormRow, type EuiButtonGroupOptionProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldStatsInitializerViewType } from '../grid_embeddable/types';
const viewTypeOptions: EuiButtonGroupOptionProps[] = [
{
id: FieldStatsInitializerViewType.DATA_VIEW,
label: (
<FormattedMessage
id="xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceSelector.dataViewLabel"
defaultMessage="Data view"
/>
),
iconType: 'visLine',
},
{
id: FieldStatsInitializerViewType.ESQL,
label: (
<FormattedMessage
id="xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceSelector.esqlLabel"
defaultMessage="ES|QL"
/>
),
iconType: 'visTable',
},
];
const dataSourceLabel = i18n.translate(
'xpack.dataVisualizer.fieldStatsDashboardPanel.dataSourceLabel',
{
defaultMessage: 'Data source',
}
);
const dataSourceAriaLabel = i18n.translate(
'xpack.dataVisualizer.fieldStatsDashboardPanel.viewTypeLabel',
{
defaultMessage: 'Pick type of data source to use',
}
);
export interface ViewTypeSelectorProps {
value: FieldStatsInitializerViewType;
onChange: (update: FieldStatsInitializerViewType) => void;
}
export const DataSourceTypeSelector: FC<ViewTypeSelectorProps> = ({ value, onChange }) => {
return (
<EuiFormRow fullWidth label={dataSourceLabel}>
<EuiButtonGroup
isFullWidth
aria-label={dataSourceAriaLabel}
options={viewTypeOptions}
idSelected={value}
onChange={onChange as (id: string) => void}
legend={dataSourceAriaLabel}
/>
</EuiFormRow>
);
};

View file

@ -0,0 +1,16 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { EmbeddableSetup } from '@kbn/embeddable-plugin/public';
import type { DataVisualizerCoreSetup } from '../../../../plugin';
import { FIELD_STATS_EMBEDDABLE_TYPE } from './constants';
export const registerEmbeddables = (embeddable: EmbeddableSetup, core: DataVisualizerCoreSetup) => {
embeddable.registerReactEmbeddableFactory(FIELD_STATS_EMBEDDABLE_TYPE, async () => {
const { getFieldStatsChartEmbeddableFactory } = await import('./field_stats_factory');
return getFieldStatsChartEmbeddableFactory(core.getStartServices);
});
};

View file

@ -0,0 +1,89 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { AggregateQuery } from '@kbn/es-query';
import type { StateComparators } from '@kbn/presentation-publishing';
import { BehaviorSubject } from 'rxjs';
import fastIsEqual from 'fast-deep-equal';
import { FieldStatsInitializerViewType } from '../grid_embeddable/types';
import type { FieldStatsInitialState } from '../grid_embeddable/types';
import type { FieldStatsControlsApi } from './types';
export const initializeFieldStatsControls = (rawState: FieldStatsInitialState) => {
const viewType$ = new BehaviorSubject<FieldStatsInitializerViewType | undefined>(
rawState.viewType ?? FieldStatsInitializerViewType.ESQL
);
const dataViewId$ = new BehaviorSubject<string | undefined>(rawState.dataViewId);
const query$ = new BehaviorSubject<AggregateQuery | undefined>(rawState.query);
const showDistributions$ = new BehaviorSubject<boolean | undefined>(rawState.showDistributions);
const resetData$ = new BehaviorSubject<number>(Date.now());
const dataLoading$ = new BehaviorSubject<boolean | undefined>(true);
const blockingError = new BehaviorSubject<Error | undefined>(undefined);
const updateUserInput = (update: FieldStatsInitialState, shouldResetData = false) => {
if (shouldResetData) {
resetData$.next(Date.now());
}
viewType$.next(update.viewType);
dataViewId$.next(update.dataViewId);
query$.next(update.query);
};
const serializeFieldStatsChartState = (): FieldStatsInitialState => {
return {
viewType: viewType$.getValue(),
dataViewId: dataViewId$.getValue(),
query: query$.getValue(),
showDistributions: showDistributions$.getValue(),
};
};
const fieldStatsControlsComparators: StateComparators<FieldStatsInitialState> = {
viewType: [viewType$, (arg) => viewType$.next(arg)],
dataViewId: [dataViewId$, (arg) => dataViewId$.next(arg)],
query: [query$, (arg) => query$.next(arg), fastIsEqual],
showDistributions: [showDistributions$, (arg) => showDistributions$.next(arg)],
};
const onRenderComplete = () => dataLoading$.next(false);
const onLoading = (v: boolean) => dataLoading$.next(v);
const onError = (error?: Error) => blockingError.next(error);
return {
fieldStatsControlsApi: {
viewType$,
dataViewId$,
query$,
updateUserInput,
showDistributions$,
} as unknown as FieldStatsControlsApi,
dataLoadingApi: {
dataLoading: dataLoading$,
onRenderComplete,
onLoading,
onError,
blockingError,
},
// Reset data is internal state management, so no need to expose this in api
resetData$,
serializeFieldStatsChartState,
fieldStatsControlsComparators,
onFieldStatsTableDestroy: () => {
viewType$.complete();
dataViewId$.complete();
query$.complete();
resetData$.complete();
},
};
};

View file

@ -0,0 +1,108 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart } from '@kbn/core/public';
import { tracksOverlays } from '@kbn/presentation-containers';
import { toMountPoint } from '@kbn/react-kibana-mount';
import React from 'react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import { isDefined } from '@kbn/ml-is-defined';
import { FieldStatisticsInitializer } from './field_stats_initializer';
import type { DataVisualizerStartDependencies } from '../../../common/types/data_visualizer_plugin';
import type {
FieldStatisticsTableEmbeddableState,
FieldStatsInitialState,
} from '../grid_embeddable/types';
import type { FieldStatsControlsApi } from './types';
import { getOrCreateDataViewByIndexPattern } from '../../search_strategy/requests/get_data_view_by_index_pattern';
export async function resolveEmbeddableFieldStatsUserInput(
coreStart: CoreStart,
pluginStart: DataVisualizerStartDependencies,
parentApi: unknown,
focusedPanelId: string,
isNewPanel: boolean,
initialState?: FieldStatisticsTableEmbeddableState,
fieldStatsControlsApi?: FieldStatsControlsApi
): Promise<FieldStatisticsTableEmbeddableState> {
const { overlays } = coreStart;
const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined;
const services = {
...coreStart,
...pluginStart,
};
let hasChanged = false;
return new Promise(async (resolve, reject) => {
try {
const cancelChanges = () => {
// Reset to initialState in case user has changed the preview state
if (hasChanged && fieldStatsControlsApi && initialState) {
fieldStatsControlsApi.updateUserInput(initialState);
}
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const update = async (nextUpdate: FieldStatsInitialState) => {
const esqlQuery = nextUpdate?.query?.esql;
if (isDefined(esqlQuery)) {
const indexPatternFromQuery = getIndexPatternFromESQLQuery(esqlQuery);
const dv = await getOrCreateDataViewByIndexPattern(
pluginStart.data.dataViews,
indexPatternFromQuery,
undefined
);
if (dv?.id && nextUpdate.dataViewId !== dv.id) {
nextUpdate.dataViewId = dv.id;
}
}
resolve(nextUpdate);
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const flyoutSession = overlays.openFlyout(
toMountPoint(
<KibanaContextProvider services={services}>
<FieldStatisticsInitializer
initialInput={initialState}
onPreview={async (nextUpdate) => {
if (fieldStatsControlsApi) {
fieldStatsControlsApi.updateUserInput(nextUpdate);
hasChanged = true;
}
}}
onCreate={update}
onCancel={cancelChanges}
isNewPanel={isNewPanel}
/>
</KibanaContextProvider>,
coreStart
),
{
ownFocus: true,
size: 's',
paddingSize: 'm',
hideCloseButton: true,
type: 'push',
'data-test-subj': 'fieldStatisticsInitializerFlyout',
onClose: cancelChanges,
}
);
if (tracksOverlays(parentApi)) {
parentApi.openOverlay(flyoutSession, { focusedPanelId });
}
} catch (error) {
reject(error);
}
});
}

View file

@ -0,0 +1,39 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import type { AggregateQuery } from '@kbn/es-query';
import type {
HasEditCapabilities,
PublishesBlockingError,
PublishesDataLoading,
PublishesDataViews,
PublishesTimeRange,
} from '@kbn/presentation-publishing';
import type { BehaviorSubject } from 'rxjs';
import type {
FieldStatisticsTableEmbeddableState,
FieldStatsInitializerViewType,
FieldStatsInitialState,
} from '../grid_embeddable/types';
export interface FieldStatsControlsApi {
viewType$: BehaviorSubject<FieldStatsInitializerViewType>;
dataViewId$: BehaviorSubject<string>;
query$: BehaviorSubject<AggregateQuery>;
showDistributions$: BehaviorSubject<boolean>;
updateUserInput: (update: Partial<FieldStatsInitialState>) => void;
}
export type FieldStatisticsTableEmbeddableApi =
DefaultEmbeddableApi<FieldStatisticsTableEmbeddableState> &
HasEditCapabilities &
PublishesDataViews &
PublishesTimeRange &
PublishesDataLoading &
PublishesBlockingError &
FieldStatsControlsApi;

View file

@ -23,7 +23,7 @@ const restorableDefaults = getDefaultESQLDataVisualizerListState();
const EmbeddableESQLFieldStatsTableWrapper = React.memo(
(props: ESQLDataVisualizerGridEmbeddableState) => {
const { onTableUpdate } = props;
const { onTableUpdate, onRenderComplete } = props;
const [dataVisualizerListState, setDataVisualizerListState] =
useState<Required<ESQLDataVisualizerIndexBasedAppState>>(restorableDefaults);
@ -44,15 +44,30 @@ const EmbeddableESQLFieldStatsTableWrapper = React.memo(
overallStatsProgress,
setLastRefresh,
getItemIdToExpandedRowMap,
resetData,
} = useESQLDataVisualizerData(props, dataVisualizerListState);
useEffect(() => {
setLastRefresh(Date.now());
}, [props?.lastReloadRequestTime, setLastRefresh]);
useEffect(() => {
const subscription = props.resetData$?.subscribe(() => {
resetData();
});
return () => subscription?.unsubscribe();
}, [props.resetData$, resetData]);
useEffect(() => {
if (progress === 100 && onRenderComplete) {
onRenderComplete();
}
}, [progress, onRenderComplete]);
if (progress === 100 && configs.length === 0) {
return <EmbeddableNoResultsEmptyPrompt />;
}
return (
<DataVisualizerTable<FieldVisConfig>
items={configs}

View file

@ -23,7 +23,7 @@ const restorableDefaults = getDefaultDataVisualizerListState();
const EmbeddableFieldStatsTableWrapper = (
props: Required<FieldStatisticTableEmbeddableProps, 'dataView'>
) => {
const { onTableUpdate, onAddFilter } = props;
const { onTableUpdate, onAddFilter, onRenderComplete } = props;
const [dataVisualizerListState, setDataVisualizerListState] =
useState<Required<DataVisualizerIndexBasedAppState>>(restorableDefaults);
@ -73,6 +73,12 @@ const EmbeddableFieldStatsTableWrapper = (
[props.dataView, searchQueryLanguage, searchString, props.totalDocuments, onAddFilter]
);
useEffect(() => {
if (progress === 100 && onRenderComplete) {
onRenderComplete();
}
}, [progress, onRenderComplete]);
if (progress === 100 && configs.length === 0) {
return <EmbeddableNoResultsEmptyPrompt />;
}

View file

@ -30,21 +30,62 @@ const EmbeddableFieldStatsTableWrapper = dynamic(() => import('./embeddable_fiel
function isESQLFieldStatisticTableEmbeddableState(
input: FieldStatisticTableEmbeddableProps
): input is ESQLDataVisualizerGridEmbeddableState {
return isPopulatedObject(input, ['esql']) && input.esql === true;
return isPopulatedObject(input, ['isEsqlMode']) && input.isEsqlMode === true;
}
function isFieldStatisticTableEmbeddableState(
input: FieldStatisticTableEmbeddableProps
): input is Required<FieldStatisticTableEmbeddableProps, 'dataView'> {
return isPopulatedObject(input, ['dataView']) && Boolean(input.esql) === false;
return isPopulatedObject(input, ['dataView']) && Boolean(input.isEsqlMode) === false;
}
const FieldStatisticsWrapperContent = (props: FieldStatisticTableEmbeddableProps) => {
if (isESQLFieldStatisticTableEmbeddableState(props)) {
return <EmbeddableESQLFieldStatsTableWrapper {...props} />;
return (
<EmbeddableESQLFieldStatsTableWrapper
dataView={props.dataView}
esqlQuery={props.esqlQuery}
isEsqlMode={props.isEsqlMode ?? props.esql}
filters={props.filters}
lastReloadRequestTime={props.lastReloadRequestTime}
onAddFilter={props.onAddFilter}
onTableUpdate={props.onTableUpdate}
query={props.query}
samplingOption={props.samplingOption}
savedSearch={props.savedSearch}
sessionId={props.sessionId}
shouldGetSubfields={props.shouldGetSubfields}
showPreviewByDefault={props.showPreviewByDefault}
totalDocuments={props.totalDocuments}
timeRange={props.timeRange}
visibleFieldNames={props.visibleFieldNames}
resetData$={props.resetData$}
onRenderComplete={props.onRenderComplete}
/>
);
}
if (isFieldStatisticTableEmbeddableState(props)) {
return <EmbeddableFieldStatsTableWrapper {...props} />;
return (
<EmbeddableFieldStatsTableWrapper
dataView={props.dataView}
isEsqlMode={false}
filters={props.filters}
lastReloadRequestTime={props.lastReloadRequestTime}
onAddFilter={props.onAddFilter}
onTableUpdate={props.onTableUpdate}
query={props.query}
samplingOption={props.samplingOption}
savedSearch={props.savedSearch}
sessionId={props.sessionId}
shouldGetSubfields={props.shouldGetSubfields}
showPreviewByDefault={props.showPreviewByDefault}
totalDocuments={props.totalDocuments}
timeRange={props.timeRange}
visibleFieldNames={props.visibleFieldNames}
resetData$={props.resetData$}
onRenderComplete={props.onRenderComplete}
/>
);
} else {
return (
<EuiEmptyPrompt
@ -133,7 +174,8 @@ const FieldStatisticsWrapper = (props: FieldStatisticTableEmbeddableProps) => {
<DatePickerContextProvider {...datePickerDeps}>
<FieldStatisticsWrapperContent
dataView={props.dataView}
esql={props.esql}
isEsqlMode={props.isEsqlMode ?? props.esql}
esqlQuery={props.esqlQuery}
filters={props.filters}
lastReloadRequestTime={props.lastReloadRequestTime}
onAddFilter={props.onAddFilter}
@ -146,6 +188,9 @@ const FieldStatisticsWrapper = (props: FieldStatisticTableEmbeddableProps) => {
showPreviewByDefault={props.showPreviewByDefault}
totalDocuments={props.totalDocuments}
visibleFieldNames={props.visibleFieldNames}
resetData$={props.resetData$}
timeRange={props.timeRange}
onRenderComplete={props.onRenderComplete}
/>
</DatePickerContextProvider>
</KibanaContextProvider>

View file

@ -5,13 +5,12 @@
* 2.0.
*/
import type { AggregateQuery, Filter } from '@kbn/es-query';
import type { AggregateQuery, Filter, TimeRange } from '@kbn/es-query';
import type { Query } from '@kbn/es-query';
import type { DataView, DataViewField } from '@kbn/data-views-plugin/public';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { BehaviorSubject } from 'rxjs';
import type { DefaultEmbeddableApi } from '@kbn/embeddable-plugin/public';
import type { SerializedTitles } from '@kbn/presentation-publishing';
import type { BehaviorSubject, Observable } from 'rxjs';
import type { SerializedTimeRange, SerializedTitles } from '@kbn/presentation-publishing';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { DataVisualizerTableState } from '../../../../../common/types';
import type { SamplingOption } from '../../../../../common/types/field_stats';
@ -82,6 +81,8 @@ export interface FieldStatisticTableEmbeddableProps {
* If esql:true, switch table to ES|QL mode
*/
esql?: boolean;
isEsqlMode?: boolean;
esqlQuery?: AggregateQuery;
/**
* If esql:true, the index pattern is used to validate time field
*/
@ -98,6 +99,9 @@ export interface FieldStatisticTableEmbeddableProps {
*/
overridableServices?: { data: DataPublicPluginStart };
renderFieldName?: (fieldName: string, item: DataVisualizerTableItem) => JSX.Element;
resetData$?: Observable<number>;
timeRange?: TimeRange;
onRenderComplete?: () => void;
}
export type ESQLDataVisualizerGridEmbeddableState = Omit<
@ -105,12 +109,21 @@ export type ESQLDataVisualizerGridEmbeddableState = Omit<
'query'
> & { query?: ESQLQuery };
export type FieldStatisticsTableEmbeddableState = FieldStatisticTableEmbeddableProps &
SerializedTitles;
interface FieldStatisticsTableEmbeddableComponentApi {
showDistributions$?: BehaviorSubject<boolean>;
export enum FieldStatsInitializerViewType {
DATA_VIEW = 'dataview',
ESQL = 'esql',
}
export interface FieldStatsInitialState {
dataViewId?: string;
viewType?: FieldStatsInitializerViewType;
query?: AggregateQuery;
showDistributions?: boolean;
}
export type FieldStatisticsTableEmbeddableState = FieldStatsInitialState &
SerializedTitles &
SerializedTimeRange & {};
export type OnAddFilter = (field: DataViewField | string, value: string, type: '+' | '-') => void;
export interface FieldStatisticsTableEmbeddableParentApi {
executionContext?: { value: string };
@ -119,10 +132,6 @@ export interface FieldStatisticsTableEmbeddableParentApi {
onAddFilter?: OnAddFilter;
}
export type FieldStatisticsTableEmbeddableApi =
DefaultEmbeddableApi<FieldStatisticsTableEmbeddableState> &
FieldStatisticsTableEmbeddableComponentApi;
export type DataVisualizerGridEmbeddableApi = Partial<FieldStatisticsTableEmbeddableState>;
export type ESQLDefaultLimitSizeOption = '5000' | '10000' | '100000';

View file

@ -20,6 +20,7 @@ import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import type { AggregateQuery, Query } from '@kbn/es-query';
import { useTimeBuckets } from '@kbn/ml-time-buckets';
import { buildEsQuery } from '@kbn/es-query';
import usePrevious from 'react-use/lib/usePrevious';
import type { FieldVisConfig } from '../../../../../common/types/field_vis_config';
import type { SupportedFieldType } from '../../../../../common/types/job_field_type';
import type { ItemIdToExpandedRowMap } from '../../../common/components/stats_table';
@ -82,7 +83,7 @@ export const useESQLDataVisualizerData = (
) => {
const [lastRefresh, setLastRefresh] = useState(0);
const { services } = useDataVisualizerKibana();
const { uiSettings, fieldFormats, executionContext } = services;
const { uiSettings, executionContext, data } = services;
const parentExecutionContext = useObservable(executionContext?.context$);
@ -107,17 +108,20 @@ export const useESQLDataVisualizerData = (
autoRefreshSelector: true,
});
const [delayedESQLQuery, setDelayedESQLQuery] = useState<ESQLQuery | undefined>(input?.esqlQuery);
const previousQuery = usePrevious(delayedESQLQuery);
const { currentDataView, parentQuery, parentFilters, query, visibleFieldNames, indexPattern } =
useMemo(() => {
let q = FALLBACK_ESQL_QUERY;
if (input?.query && isESQLQuery(input?.query)) q = input.query;
if (delayedESQLQuery && isESQLQuery(delayedESQLQuery)) q = delayedESQLQuery;
if (input?.savedSearch && isESQLQuery(input.savedSearch.searchSource.getField('query'))) {
q = input.savedSearch.searchSource.getField('query') as ESQLQuery;
}
return {
currentDataView: input.dataView,
query: q ?? FALLBACK_ESQL_QUERY,
query: q,
// It's possible that in a dashboard setting, we will have additional filters and queries
parentQuery: input?.query,
parentFilters: input?.filters,
@ -131,6 +135,7 @@ export const useESQLDataVisualizerData = (
input?.filters,
input?.visibleFieldNames,
input?.indexPattern,
delayedESQLQuery,
]);
const restorableDefaults = useMemo(
@ -181,6 +186,7 @@ export const useESQLDataVisualizerData = (
(Array.isArray(parentQuery) ? parentQuery : [parentQuery]) as AnyQuery | AnyQuery[],
parentFilters ?? []
);
const timeRange = input.timeRange ? input.timeRange : timefilter.getTime();
if (currentDataView?.timeFieldName) {
if (Array.isArray(filter?.bool?.filter)) {
@ -188,8 +194,8 @@ export const useESQLDataVisualizerData = (
range: {
[currentDataView.timeFieldName]: {
format: 'strict_date_optional_time',
gte: timefilter.getTime().from,
lte: timefilter.getTime().to,
gte: timeRange.from,
lte: timeRange.to,
},
},
});
@ -202,8 +208,8 @@ export const useESQLDataVisualizerData = (
range: {
[currentDataView.timeFieldName]: {
format: 'strict_date_optional_time',
gte: timefilter.getTime().from,
lte: timefilter.getTime().to,
gte: timeRange.from,
lte: timeRange.to,
},
},
},
@ -239,6 +245,8 @@ export const useESQLDataVisualizerData = (
indexPattern,
lastRefresh,
limitSize,
input.timeRange?.from,
input.timeRange?.to,
]
);
@ -420,7 +428,7 @@ export const useESQLDataVisualizerData = (
...field,
...fieldData,
loading: fieldData?.existsInDocs ?? true,
fieldFormat: fieldFormats.deserialize({ id: field.secondaryType }),
fieldFormat: data.fieldFormats.deserialize({ id: field.secondaryType }),
aggregatable: true,
deletable: false,
type: getFieldType(field) as SupportedFieldType,
@ -493,7 +501,7 @@ export const useESQLDataVisualizerData = (
secondaryType: getFieldType(field) as SupportedFieldType,
loading: fieldData?.existsInDocs ?? true,
deletable: false,
fieldFormat: fieldFormats.deserialize({ id: field.secondaryType }),
fieldFormat: data.fieldFormats.deserialize({ id: field.secondaryType }),
};
// Map the field type from the Kibana index pattern to the field type
@ -598,12 +606,14 @@ export const useESQLDataVisualizerData = (
totalDocuments={totalCount}
typeAccessor="secondaryType"
timeFieldName={timeFieldName}
onAddFilter={input.onAddFilter}
/>
);
}
return map;
}, {} as ItemIdToExpandedRowMap);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[currentDataView, totalCount, query.esql, timeFieldName]
);
@ -633,6 +643,14 @@ export const useESQLDataVisualizerData = (
[cancelFieldStatsRequest, cancelOverallStatsRequest]
);
useEffect(() => {
if (previousQuery?.esql !== input?.esqlQuery?.esql) {
resetData();
setDelayedESQLQuery(input?.esqlQuery);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [input?.esqlQuery?.esql, resetData]);
return {
totalCount,
progress: combinedProgress,

View file

@ -49,7 +49,7 @@ import {
getDefaultPageState,
} from '../constants/index_data_visualizer_viewer';
import { getFieldsWithSubFields } from '../utils/get_fields_with_subfields_utils';
import type { FieldStatisticsTableEmbeddableState } from '../embeddables/grid_embeddable/types';
import type { FieldStatisticTableEmbeddableProps } from '../embeddables/grid_embeddable/types';
const defaults = getDefaultPageState();
@ -64,7 +64,7 @@ const DEFAULT_SAMPLING_OPTION: SamplingOption = {
};
export const useDataVisualizerGridData = (
// Data view is required for non-ES|QL queries like kuery or lucene
input: Required<FieldStatisticsTableEmbeddableState, 'dataView'>,
input: Required<FieldStatisticTableEmbeddableProps, 'dataView'>,
dataVisualizerListState: Required<DataVisualizerIndexBasedAppState>,
savedRandomSamplerPreference?: RandomSamplerOption,
onUpdate?: (params: Dictionary<unknown>) => void
@ -207,17 +207,19 @@ export const useDataVisualizerGridData = (
const tf = timefilter;
if (!buckets || !tf || !currentDataView) return;
if (!buckets || !tf || !currentDataView || lastRefresh === 0) return;
const activeBounds = tf.getActiveBounds();
let earliest: number | undefined;
let latest: number | undefined;
let earliest: number | string | undefined;
let latest: number | string | undefined;
if (activeBounds !== undefined && currentDataView.timeFieldName !== undefined) {
earliest = activeBounds.min?.valueOf();
latest = activeBounds.max?.valueOf();
}
if (input.timeRange) {
earliest = input.timeRange.from;
latest = input.timeRange.to;
}
const bounds = tf.getActiveBounds();
const barTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ?? DEFAULT_BAR_TARGET;
buckets.setInterval('auto');
@ -264,23 +266,19 @@ export const useDataVisualizerGridData = (
nonAggregatableFields,
browserSessionSeed,
samplingOption: { ...samplingOption, seed: browserSessionSeed.toString() },
componentExecutionContext,
embeddableExecutionContext: componentExecutionContext,
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
_timeBuckets,
timefilter,
currentDataView.id,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(searchQuery),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(samplingOption),
searchSessionId,
lastRefresh,
fieldsToFetch,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify({ searchQuery, samplingOption, fieldsToFetch }),
searchSessionId,
browserSessionSeed,
componentExecutionContext,
input.timeRange?.from,
input.timeRange?.to,
]
);
@ -335,7 +333,6 @@ export const useDataVisualizerGridData = (
() => overallStatsProgress.loaded * 0.2 + strategyResponse.progress.loaded * 0.8,
[overallStatsProgress.loaded, strategyResponse.progress.loaded]
);
useEffect(() => {
const timeUpdateSubscription = merge(
timefilter.getTimeUpdate$(),

View file

@ -13,7 +13,8 @@ import { last, cloneDeep } from 'lodash';
import { mergeMap, switchMap } from 'rxjs';
import { Comparators } from '@elastic/eui';
import type { ISearchOptions } from '@kbn/search-types';
import { buildBaseFilterCriteria, getSafeAggregationName } from '@kbn/ml-query-utils';
import { getSafeAggregationName } from '@kbn/ml-query-utils';
import { buildFilterCriteria } from '../../../../common/utils/build_query_filters';
import type {
DataStatsFetchProgress,
FieldStatsSearchStrategyReturnBase,
@ -146,7 +147,7 @@ export function useFieldStatsSearchStrategy(
return;
}
const filterCriteria = buildBaseFilterCriteria(
const filterCriteria = buildFilterCriteria(
searchStrategyParams.timeFieldName,
searchStrategyParams.earliest,
searchStrategyParams.latest,

View file

@ -16,7 +16,6 @@ import type {
} from '@kbn/search-types';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { getProcessedFields } from '@kbn/ml-data-grid';
import { buildBaseFilterCriteria } from '@kbn/ml-query-utils';
import { isDefined } from '@kbn/ml-is-defined';
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -52,6 +51,7 @@ import {
fetchDataWithTimeout,
rateLimitingForkJoin,
} from '../search_strategy/requests/fetch_utils';
import { buildFilterCriteria } from '../../../../common/utils/build_query_filters';
const getPopulatedFieldsInIndex = (
populatedFieldsInIndexWithoutRuntimeFields: Set<string> | undefined | null,
@ -119,12 +119,7 @@ export function useOverallStats<TParams extends OverallStatsSearchStrategyParams
return;
}
const filterCriteria = buildBaseFilterCriteria(
timeFieldName,
earliest,
latest,
searchQuery
);
const filterCriteria = buildFilterCriteria(timeFieldName, earliest, latest, searchQuery);
// Getting non-empty fields for the index pattern
// because then we can absolutely exclude these from subsequent requests

View file

@ -12,12 +12,12 @@ import type { ISearchOptions } from '@kbn/search-types';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import seedrandom from 'seedrandom';
import { isDefined } from '@kbn/ml-is-defined';
import { buildBaseFilterCriteria } from '@kbn/ml-query-utils';
import { RANDOM_SAMPLER_PROBABILITIES } from '../../constants/random_sampler';
import type {
DocumentCountStats,
OverallStatsSearchStrategyParams,
} from '../../../../../common/types/field_stats';
import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters';
const MINIMUM_RANDOM_SAMPLER_DOC_COUNT = 100000;
const DEFAULT_INITIAL_RANDOM_SAMPLER_PROBABILITY = 0.000001;
@ -45,7 +45,7 @@ export const getDocumentCountStats = async (
// Probability = 1 represents no sampling
const result = { randomlySampled: false, took: 0, totalCount: 0, probability: 1 };
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, searchQuery);
const query = {
bool: {
@ -229,12 +229,11 @@ export const processDocumentCountStats = (
buckets[time] = dataForTime.doc_count;
totalCount += dataForTime.doc_count;
});
return {
interval: params.intervalMs,
buckets,
timeRangeEarliest: params.earliest,
timeRangeLatest: params.latest,
timeRangeEarliest: typeof params.earliest === 'number' ? params.earliest : undefined,
timeRangeLatest: typeof params.latest === 'number' ? params.latest : undefined,
totalCount,
};
};

View file

@ -16,7 +16,6 @@ import type {
import type { ISearchStart } from '@kbn/data-plugin/public';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import type { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { buildBaseFilterCriteria } from '@kbn/ml-query-utils';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { getUniqGeoOrStrExamples } from '../../../common/util/example_utils';
import type {
@ -27,6 +26,7 @@ import type {
} from '../../../../../common/types/field_stats';
import { isIKibanaSearchResponse } from '../../../../../common/types/field_stats';
import { MAX_EXAMPLES_DEFAULT } from './constants';
import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters';
export const getFieldExamplesRequest = (params: FieldStatsCommonRequestParams, field: Field) => {
const { index, timeFieldName, earliestMs, latestMs, query, runtimeFieldMap, maxExamples } =
@ -35,7 +35,7 @@ export const getFieldExamplesRequest = (params: FieldStatsCommonRequestParams, f
// Request at least 100 docs so that we have a chance of obtaining
// 'maxExamples' of the field.
const size = Math.max(100, maxExamples ?? MAX_EXAMPLES_DEFAULT);
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query);
// Use an exists filter to return examples of the field.
if (Array.isArray(filterCriteria)) {

View file

@ -11,7 +11,7 @@ import type { Query } from '@kbn/es-query';
import type { IKibanaSearchResponse } from '@kbn/search-types';
import type { AggCardinality } from '@kbn/ml-agg-utils';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { buildBaseFilterCriteria, getSafeAggregationName } from '@kbn/ml-query-utils';
import { getSafeAggregationName } from '@kbn/ml-query-utils';
import { buildAggregationWithSamplingOption } from './build_random_sampler_agg';
import { getDatafeedAggregations } from '../../../../../common/utils/datafeed_utils';
import type { AggregatableField, NonAggregatableField } from '../../types/overall_stats';
@ -20,6 +20,7 @@ import type {
OverallStatsSearchStrategyParams,
SamplingOption,
} from '../../../../../common/types/field_stats';
import { buildFilterCriteria } from '../../../../../common/utils/build_query_filters';
export const checkAggregatableFieldsExistRequest = (
dataViewTitle: string,
@ -27,14 +28,14 @@ export const checkAggregatableFieldsExistRequest = (
aggregatableFields: OverallStatsSearchStrategyParams['aggregatableFields'],
samplingOption: SamplingOption,
timeFieldName: string | undefined,
earliestMs?: number,
latestMs?: number,
earliestMs?: number | string,
latestMs?: number | string,
datafeedConfig?: estypes.MlDatafeed,
runtimeMappings?: estypes.MappingRuntimeFields
): estypes.SearchRequest => {
const index = dataViewTitle;
const size = 0;
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query);
const datafeedAggregations = getDatafeedAggregations(datafeedConfig);
// Value count aggregation faster way of checking if field exists than using
@ -217,13 +218,13 @@ export const checkNonAggregatableFieldExistsRequest = (
query: Query['query'],
field: string,
timeFieldName: string | undefined,
earliestMs: number | undefined,
latestMs: number | undefined,
earliestMs: number | string | undefined,
latestMs: number | string | undefined,
runtimeMappings?: estypes.MappingRuntimeFields
): estypes.SearchRequest => {
const index = dataViewTitle;
const size = 0;
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query);
if (Array.isArray(filterCriteria)) {
filterCriteria.push({ exists: { field } });
@ -256,12 +257,12 @@ export const getSampleOfDocumentsForNonAggregatableFields = (
dataViewTitle: string,
query: Query['query'],
timeFieldName: string | undefined,
earliestMs: number | undefined,
latestMs: number | undefined,
earliestMs: number | string | undefined,
latestMs: number | string | undefined,
runtimeMappings?: estypes.MappingRuntimeFields
): estypes.SearchRequest => {
const index = dataViewTitle;
const filterCriteria = buildBaseFilterCriteria(timeFieldName, earliestMs, latestMs, query);
const filterCriteria = buildFilterCriteria(timeFieldName, earliestMs, latestMs, query);
return {
index,

View file

@ -0,0 +1,187 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type { PresentationContainer } from '@kbn/presentation-containers';
import { tracksOverlays } from '@kbn/presentation-containers';
import type { EmbeddableApiContext } from '@kbn/presentation-publishing';
import type { UiActionsActionDefinition } from '@kbn/ui-actions-plugin/public';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import { isDefined } from '@kbn/ml-is-defined';
import { COMMON_VISUALIZATION_GROUPING } from '@kbn/visualizations-plugin/public';
import { FIELD_STATS_EMBEDDABLE_TYPE } from '../embeddables/field_stats/constants';
import type { DataVisualizerStartDependencies } from '../../common/types/data_visualizer_plugin';
import type {
FieldStatisticsTableEmbeddableApi,
FieldStatsControlsApi,
} from '../embeddables/field_stats/types';
import { FieldStatsInitializerViewType } from '../embeddables/grid_embeddable/types';
import type { FieldStatsInitialState } from '../embeddables/grid_embeddable/types';
import { getOrCreateDataViewByIndexPattern } from '../search_strategy/requests/get_data_view_by_index_pattern';
import { FieldStatisticsInitializer } from '../embeddables/field_stats/field_stats_initializer';
const parentApiIsCompatible = async (
parentApi: unknown
): Promise<PresentationContainer | undefined> => {
const { apiIsPresentationContainer } = await import('@kbn/presentation-containers');
// we cannot have an async type check, so return the casted parentApi rather than a boolean
return apiIsPresentationContainer(parentApi) ? (parentApi as PresentationContainer) : undefined;
};
interface FieldStatsActionContext extends EmbeddableApiContext {
embeddable: FieldStatisticsTableEmbeddableApi;
}
async function updatePanelFromFlyoutEdits({
api,
isNewPanel,
deletePanel,
coreStart,
pluginStart,
initialState,
}: {
api: FieldStatisticsTableEmbeddableApi;
isNewPanel: boolean;
deletePanel?: () => void;
coreStart: CoreStart;
pluginStart: DataVisualizerStartDependencies;
initialState: FieldStatsInitialState;
fieldStatsControlsApi?: FieldStatsControlsApi;
}) {
const parentApi = api.parentApi;
const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined;
const services = {
...coreStart,
...pluginStart,
};
let hasChanged = false;
const cancelChanges = () => {
// Reset to initialState in case user has changed the preview state
if (hasChanged && api && initialState) {
api.updateUserInput(initialState);
}
if (isNewPanel && deletePanel) {
deletePanel();
}
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const update = async (nextUpdate: FieldStatsInitialState) => {
const esqlQuery = nextUpdate?.query?.esql;
if (isDefined(esqlQuery)) {
const indexPatternFromQuery = getIndexPatternFromESQLQuery(esqlQuery);
const dv = await getOrCreateDataViewByIndexPattern(
pluginStart.data.dataViews,
indexPatternFromQuery,
undefined
);
if (dv?.id && nextUpdate.dataViewId !== dv.id) {
nextUpdate.dataViewId = dv.id;
}
}
if (api) {
api.updateUserInput(nextUpdate);
}
flyoutSession.close();
overlayTracker?.clearOverlays();
};
const flyoutSession = services.overlays.openFlyout(
toMountPoint(
<KibanaContextProvider services={services}>
<FieldStatisticsInitializer
initialInput={initialState}
onPreview={async (nextUpdate) => {
if (api.updateUserInput) {
api.updateUserInput(nextUpdate);
hasChanged = true;
}
}}
onCreate={update}
onCancel={cancelChanges}
isNewPanel={isNewPanel}
/>
</KibanaContextProvider>,
coreStart
),
{
ownFocus: true,
size: 's',
paddingSize: 'm',
hideCloseButton: true,
type: 'push',
'data-test-subj': 'fieldStatisticsInitializerFlyout',
onClose: cancelChanges,
}
);
overlayTracker?.openOverlay(flyoutSession, { focusedPanelId: api.uuid });
}
export function createAddFieldStatsTableAction(
coreStart: CoreStart,
pluginStart: DataVisualizerStartDependencies
): UiActionsActionDefinition<FieldStatsActionContext> {
return {
id: 'create-field-stats-table',
grouping: COMMON_VISUALIZATION_GROUPING,
order: 10,
getIconType: () => 'inspect',
getDisplayName: () =>
i18n.translate('xpack.dataVisualizer.fieldStatistics.displayName', {
defaultMessage: 'Field statistics',
}),
async isCompatible(context: EmbeddableApiContext) {
return Boolean(await parentApiIsCompatible(context.embeddable));
},
async execute(context) {
const presentationContainerParent = await parentApiIsCompatible(context.embeddable);
if (!presentationContainerParent) throw new IncompatibleActionError();
try {
const defaultIndexPattern = await pluginStart.data.dataViews.getDefault();
const defaultInitialState: FieldStatsInitialState = {
viewType: FieldStatsInitializerViewType.ESQL,
query: {
// Initial default query
esql: `from ${defaultIndexPattern?.getIndexPattern()} | limit 10`,
},
};
const embeddable = await presentationContainerParent.addNewPanel<
object,
FieldStatisticsTableEmbeddableApi
>({
panelType: FIELD_STATS_EMBEDDABLE_TYPE,
initialState: defaultInitialState,
});
// open the flyout if embeddable has been created successfully
if (embeddable) {
const deletePanel = () => {
presentationContainerParent.removePanel(embeddable.uuid);
};
updatePanelFromFlyoutEdits({
api: embeddable,
isNewPanel: true,
deletePanel,
coreStart,
pluginStart,
initialState: defaultInitialState,
});
}
} catch (e) {
return Promise.reject(e);
}
},
};
}

View file

@ -0,0 +1,21 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
import type { DataVisualizerStartDependencies } from '../../common/types/data_visualizer_plugin';
export function registerDataVisualizerUiActions(
uiActions: UiActionsSetup,
coreStart: CoreStart,
pluginStart: DataVisualizerStartDependencies
) {
import('./create_field_stats_table').then(({ createAddFieldStatsTableAction }) => {
const addFieldStatsAction = createAddFieldStatsTableAction(coreStart, pluginStart);
uiActions.addTriggerAction('ADD_PANEL_TRIGGER', addFieldStatsAction);
});
}

View file

@ -6,7 +6,7 @@
*/
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import { isDefined } from '@kbn/ml-is-defined';
import type { FieldStatisticsTableEmbeddableState } from '../embeddables/grid_embeddable/types';
import type { FieldStatisticTableEmbeddableProps } from '../embeddables/grid_embeddable/types';
/**
* Helper logic to add multi-fields to the table for embeddables outside of Index data visualizer
@ -19,7 +19,7 @@ export const getFieldsWithSubFields = ({
currentDataView,
shouldGetSubfields = false,
}: {
input: FieldStatisticsTableEmbeddableState;
input: FieldStatisticTableEmbeddableProps;
currentDataView: DataView;
shouldGetSubfields: boolean;
}) => {

View file

@ -20,6 +20,8 @@ import type {
DataVisualizerSetupDependencies,
DataVisualizerStartDependencies,
} from './application/common/types/data_visualizer_plugin';
import { registerEmbeddables } from './application/index_data_visualizer/embeddables/field_stats';
import { registerDataVisualizerUiActions } from './application/index_data_visualizer/ui_actions';
export type DataVisualizerPluginSetup = ReturnType<DataVisualizerPlugin['setup']>;
export type DataVisualizerPluginStart = ReturnType<DataVisualizerPlugin['start']>;
@ -50,7 +52,17 @@ export class DataVisualizerPlugin
}
}
public setup(core: DataVisualizerCoreSetup, plugins: DataVisualizerSetupDependencies) {
public async setup(core: DataVisualizerCoreSetup, plugins: DataVisualizerSetupDependencies) {
if (plugins.embeddable) {
registerEmbeddables(plugins.embeddable, core);
}
const [coreStart, pluginStart] = await core.getStartServices();
if (plugins.uiActions) {
registerDataVisualizerUiActions(plugins.uiActions, coreStart, pluginStart);
}
if (plugins.home) {
registerHomeAddData(plugins.home, this.resultsLinks);
registerHomeFeatureCatalogue(plugins.home);
@ -75,7 +87,7 @@ export class DataVisualizerPlugin
FieldStatisticsTable: dynamic(
async () =>
import(
'./application/index_data_visualizer/embeddables/grid_embeddable/field_stats_embeddable_wrapper'
'./application/index_data_visualizer/embeddables/grid_embeddable/field_stats_wrapper'
)
),
};

View file

@ -82,7 +82,12 @@
"@kbn/presentation-publishing",
"@kbn/shared-ux-utility",
"@kbn/search-types",
"@kbn/unified-field-list"
"@kbn/unified-field-list",
"@kbn/content-management-utils",
"@kbn/core-lifecycle-browser",
"@kbn/presentation-containers",
"@kbn/react-kibana-mount",
"@kbn/visualizations-plugin"
],
"exclude": [
"target/**/*",

View file

@ -44225,8 +44225,6 @@
"uiActions.errors.incompatibleAction": "Action non compatible",
"uiActions.triggers.rowClickkDescription": "Un clic sur une ligne de tableau",
"uiActions.triggers.rowClickTitle": "Clic sur ligne de tableau",
"uiActions.triggers.dashboard.addPanelMenu.description": "Une nouvelle action apparaîtra dans le menu Ajouter un panneau du tableau de bord",
"uiActions.triggers.dashboard.addPanelMenu.title": "Menu Ajouter un panneau",
"unsavedChangesBadge.contextMenu.openButton": "Afficher les actions disponibles",
"unsavedChangesBadge.contextMenu.revertChangesButton": "Restaurer les modifications",
"unsavedChangesBadge.contextMenu.revertingChangesButtonStatus": "Annuler les modifications",