diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index b30fcf972eda55..32704d95423f71 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -19,5 +19,6 @@ export const DOC_TABLE_LEGACY = 'doc_table:legacy'; export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; export const SEARCH_FIELDS_FROM_SOURCE = 'discover:searchFieldsFromSource'; export const MAX_DOC_FIELDS_DISPLAYED = 'discover:maxDocFieldsDisplayed'; +export const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics'; export const SHOW_MULTIFIELDS = 'discover:showMultiFields'; export const SEARCH_EMBEDDABLE_TYPE = 'search'; diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx index 15f6e619c86508..f7a383be76b9e0 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.test.tsx @@ -19,6 +19,7 @@ import { discoverServiceMock } from '../../../../../__mocks__/services'; import { FetchStatus } from '../../../../types'; import { Chart } from './point_series'; import { DiscoverChart } from './discover_chart'; +import { VIEW_MODE } from '../view_mode_toggle'; setHeaderActionMenuMounter(jest.fn()); @@ -94,6 +95,8 @@ function getProps(timefield?: string) { state: { columns: [] }, stateContainer: {} as GetStateReturn, timefield, + viewMode: VIEW_MODE.DOCUMENT_LEVEL, + setDiscoverViewMode: jest.fn(), }; } diff --git a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx index b6509356c8c417..166c2272a00f42 100644 --- a/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx +++ b/src/plugins/discover/public/application/apps/main/components/chart/discover_chart.tsx @@ -23,6 +23,8 @@ import { DiscoverHistogram } from './histogram'; import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search'; import { DiscoverServices } from '../../../../../build_services'; import { useChartPanels } from './use_chart_panels'; +import { VIEW_MODE, DocumentViewModeToggle } from '../view_mode_toggle'; +import { SHOW_FIELD_STATISTICS } from '../../../../../../common'; const DiscoverHistogramMemoized = memo(DiscoverHistogram); export const CHART_HIDDEN_KEY = 'discover:chartHidden'; @@ -36,6 +38,8 @@ export function DiscoverChart({ state, stateContainer, timefield, + viewMode, + setDiscoverViewMode, }: { resetSavedSearch: () => void; savedSearch: SavedSearch; @@ -45,8 +49,11 @@ export function DiscoverChart({ state: AppState; stateContainer: GetStateReturn; timefield?: string; + viewMode: VIEW_MODE; + setDiscoverViewMode: (viewMode: VIEW_MODE) => void; }) { const [showChartOptionsPopover, setShowChartOptionsPopover] = useState(false); + const showViewModeToggle = services.uiSettings.get(SHOW_FIELD_STATISTICS) ?? false; const { data, storage } = services; @@ -108,6 +115,16 @@ export function DiscoverChart({ onResetQuery={resetSavedSearch} /> + + {showViewModeToggle && ( + + + + )} + {timefield && ( (undefined); const [inspectorSession, setInspectorSession] = useState(undefined); + + const viewMode = useMemo(() => { + if (uiSettings.get(SHOW_FIELD_STATISTICS) !== true) return VIEW_MODE.DOCUMENT_LEVEL; + return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL; + }, [uiSettings, state.viewMode]); + + const setDiscoverViewMode = useCallback( + (mode: VIEW_MODE) => { + stateContainer.setAppState({ viewMode: mode }); + }, + [stateContainer] + ); + const fetchCounter = useRef(0); const dataState: DataMainMsg = useDataState(main$); @@ -213,6 +229,7 @@ export function DiscoverLayout({ trackUiMetric={trackUiMetric} useNewFieldsApi={useNewFieldsApi} onEditRuntimeField={onEditRuntimeField} + viewMode={viewMode} /> @@ -279,22 +296,36 @@ export function DiscoverLayout({ services={services} stateContainer={stateContainer} timefield={timeField} + viewMode={viewMode} + setDiscoverViewMode={setDiscoverViewMode} /> - - + {viewMode === VIEW_MODE.DOCUMENT_LEVEL ? ( + + ) : ( + + )} )} diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx index f2919f6a9bfd42..89e7b501876307 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field.tsx @@ -19,6 +19,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer, + EuiHorizontalRule, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UiCounterMetricType } from '@kbn/analytics'; @@ -251,6 +252,11 @@ export interface DiscoverFieldProps { * @param fieldName name of the field to delete */ onDeleteField?: (fieldName: string) => void; + + /** + * Optionally show or hide field stats in the popover + */ + showFieldStats?: boolean; } function DiscoverFieldComponent({ @@ -266,6 +272,7 @@ function DiscoverFieldComponent({ multiFields, onEditField, onDeleteField, + showFieldStats, }: DiscoverFieldProps) { const [infoIsOpen, setOpen] = useState(false); @@ -362,15 +369,27 @@ function DiscoverFieldComponent({ const details = getDetails(field); return ( <> - + {showFieldStats && ( + <> + +
+ {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} +
+
+ + + )} + {multiFields && ( <> - + {showFieldStats && } )} + {(showFieldStats || multiFields) && } ); }; - return ( {popoverTitle} - -
- {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { - defaultMessage: 'Top 5 values', - })} -
-
{infoIsOpen && renderPopover()}
); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx index baf740531e6bfe..e974a67aef60d0 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_field_visualize.tsx @@ -7,7 +7,7 @@ */ import React, { useEffect, useState } from 'react'; -import { EuiButton, EuiPopoverFooter } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics'; import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common'; @@ -46,21 +46,19 @@ export const DiscoverFieldVisualize: React.FC = React.memo( }; return ( - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - - - + // eslint-disable-next-line @elastic/eui/href-or-on-click + + + ); } ); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx index a550dbd59b9fa7..03616c136df3ed 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.test.tsx @@ -22,6 +22,7 @@ import { DiscoverSidebarComponent as DiscoverSidebar } from './discover_sidebar' import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { discoverServiceMock as mockDiscoverServices } from '../../../../../__mocks__/services'; import { stubLogstashIndexPattern } from '../../../../../../../data/common/stubs'; +import { VIEW_MODE } from '../view_mode_toggle'; jest.mock('../../../../../kibana_services', () => ({ getServices: () => mockDiscoverServices, @@ -65,6 +66,7 @@ function getCompProps(): DiscoverSidebarProps { setFieldFilter: jest.fn(), onEditRuntimeField: jest.fn(), editField: jest.fn(), + viewMode: VIEW_MODE.DOCUMENT_LEVEL, }; } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx index 0bd8c59b90c018..d13860eab0d242 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar.tsx @@ -40,6 +40,7 @@ import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; +import { VIEW_MODE } from '../view_mode_toggle'; /** * Default number of available fields displayed and added on scroll @@ -77,6 +78,10 @@ export interface DiscoverSidebarProps extends Omit(null); @@ -205,6 +211,8 @@ export function DiscoverSidebarComponent({ return result; }, [fields]); + const showFieldStats = useMemo(() => viewMode === VIEW_MODE.DOCUMENT_LEVEL, [viewMode]); + const calculateMultiFields = () => { if (!useNewFieldsApi || !fields) { return undefined; @@ -407,6 +415,7 @@ export function DiscoverSidebarComponent({ multiFields={multiFields?.get(field.name)} onEditField={canEditIndexPatternField ? editField : undefined} onDeleteField={canEditIndexPatternField ? deleteField : undefined} + showFieldStats={showFieldStats} /> ); @@ -466,6 +475,7 @@ export function DiscoverSidebarComponent({ multiFields={multiFields?.get(field.name)} onEditField={canEditIndexPatternField ? editField : undefined} onDeleteField={canEditIndexPatternField ? deleteField : undefined} + showFieldStats={showFieldStats} /> ); @@ -494,6 +504,7 @@ export function DiscoverSidebarComponent({ multiFields={multiFields?.get(field.name)} onEditField={canEditIndexPatternField ? editField : undefined} onDeleteField={canEditIndexPatternField ? deleteField : undefined} + showFieldStats={showFieldStats} /> ); diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx index ded7897d2a9e5a..4e4fed8c65bf7e 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.test.tsx @@ -26,6 +26,7 @@ import { ElasticSearchHit } from '../../../../doc_views/doc_views_types'; import { FetchStatus } from '../../../../types'; import { DataDocuments$ } from '../../services/use_saved_search'; import { stubLogstashIndexPattern } from '../../../../../../../data/common/stubs'; +import { VIEW_MODE } from '../view_mode_toggle'; const mockServices = { history: () => ({ @@ -103,6 +104,7 @@ function getCompProps(): DiscoverSidebarResponsiveProps { state: {}, trackUiMetric: jest.fn(), onEditRuntimeField: jest.fn(), + viewMode: VIEW_MODE.DOCUMENT_LEVEL, }; } diff --git a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx index 90357b73c68815..368a2b2e92d342 100644 --- a/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx +++ b/src/plugins/discover/public/application/apps/main/components/sidebar/discover_sidebar_responsive.tsx @@ -37,6 +37,7 @@ import { AppState } from '../../services/discover_state'; import { DiscoverIndexPatternManagement } from './discover_index_pattern_management'; import { DataDocuments$ } from '../../services/use_saved_search'; import { calcFieldCounts } from '../../utils/calc_field_counts'; +import { VIEW_MODE } from '../view_mode_toggle'; export interface DiscoverSidebarResponsiveProps { /** @@ -106,6 +107,10 @@ export interface DiscoverSidebarResponsiveProps { * callback to execute on edit runtime field */ onEditRuntimeField: () => void; + /** + * Discover view mode + */ + viewMode: VIEW_MODE; } /** diff --git a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts index 44d2999947f41d..653e878ad01bb5 100644 --- a/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts +++ b/src/plugins/discover/public/application/apps/main/components/top_nav/get_top_nav_links.ts @@ -16,6 +16,7 @@ import { SavedSearch } from '../../../../../saved_searches'; import { onSaveSearch } from './on_save_search'; import { GetStateReturn } from '../../services/discover_state'; import { openOptionsPopover } from './open_options_popover'; +import type { TopNavMenuData } from '../../../../../../../navigation/public'; /** * Helper function to build the top nav links @@ -38,7 +39,7 @@ export const getTopNavLinks = ({ onOpenInspector: () => void; searchSource: ISearchSource; onOpenSavedSearch: (id: string) => void; -}) => { +}): TopNavMenuData[] => { const options = { id: 'options', label: i18n.translate('discover.localMenu.localMenu.optionsTitle', { diff --git a/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_index.scss b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_index.scss new file mode 100644 index 00000000000000..a76c3453de32a4 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_index.scss @@ -0,0 +1 @@ +@import 'view_mode_toggle'; diff --git a/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_view_mode_toggle.scss b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_view_mode_toggle.scss new file mode 100644 index 00000000000000..1009ab0511957e --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/_view_mode_toggle.scss @@ -0,0 +1,12 @@ +.dscViewModeToggle { + padding-right: $euiSize; +} + +.fieldStatsButton { + display: flex; + align-items: center; +} + +.fieldStatsBetaBadge { + margin-left: $euiSizeXS; +} diff --git a/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/constants.ts b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/constants.ts new file mode 100644 index 00000000000000..d03c0710d12b30 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export enum VIEW_MODE { + DOCUMENT_LEVEL = 'documents', + AGGREGATED_LEVEL = 'aggregated', +} diff --git a/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/index.ts b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/index.ts new file mode 100644 index 00000000000000..95b76f5879d197 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DocumentViewModeToggle } from './view_mode_toggle'; +export { VIEW_MODE } from './constants'; diff --git a/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/view_mode_toggle.tsx b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/view_mode_toggle.tsx new file mode 100644 index 00000000000000..3aa24c05e98d43 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/components/view_mode_toggle/view_mode_toggle.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiButtonGroup, EuiBetaBadge } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { VIEW_MODE } from './constants'; +import './_index.scss'; + +export const DocumentViewModeToggle = ({ + viewMode, + setDiscoverViewMode, +}: { + viewMode: VIEW_MODE; + setDiscoverViewMode: (viewMode: VIEW_MODE) => void; +}) => { + const toggleButtons = useMemo( + () => [ + { + id: VIEW_MODE.DOCUMENT_LEVEL, + label: i18n.translate('discover.viewModes.document.label', { + defaultMessage: 'Documents', + }), + 'data-test-subj': 'dscViewModeDocumentButton', + }, + { + id: VIEW_MODE.AGGREGATED_LEVEL, + label: ( +
+ + +
+ ), + }, + ], + [] + ); + + return ( + setDiscoverViewMode(id as VIEW_MODE)} + data-test-subj={'dscViewModeToggle'} + /> + ); +}; diff --git a/src/plugins/discover/public/application/apps/main/services/discover_state.ts b/src/plugins/discover/public/application/apps/main/services/discover_state.ts index 16eb622c4a7c40..9a61fdc996e3b7 100644 --- a/src/plugins/discover/public/application/apps/main/services/discover_state.ts +++ b/src/plugins/discover/public/application/apps/main/services/discover_state.ts @@ -35,6 +35,7 @@ import { DiscoverGridSettings } from '../../../components/discover_grid/types'; import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from '../../../../url_generator'; import { SavedSearch } from '../../../../saved_searches'; import { handleSourceColumnState } from '../../../helpers/state_helpers'; +import { VIEW_MODE } from '../components/view_mode_toggle'; export interface AppState { /** @@ -73,6 +74,14 @@ export interface AppState { * id of the used saved query */ savedQuery?: string; + /** + * Table view: Documents vs Field Statistics + */ + viewMode?: VIEW_MODE; + /** + * Hide mini distribution/preview charts when in Field Statistics mode + */ + hideAggregatedPreview?: boolean; } interface GetStateParams { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts index 45447fe642ad47..6cf34fd8cb0242 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.test.ts @@ -31,6 +31,7 @@ describe('getStateDefaults', () => { "default_column", ], "filters": undefined, + "hideAggregatedPreview": undefined, "hideChart": undefined, "index": "index-pattern-with-timefield-id", "interval": "auto", @@ -42,6 +43,7 @@ describe('getStateDefaults', () => { "desc", ], ], + "viewMode": undefined, } `); }); @@ -61,12 +63,14 @@ describe('getStateDefaults', () => { "default_column", ], "filters": undefined, + "hideAggregatedPreview": undefined, "hideChart": undefined, "index": "the-index-pattern-id", "interval": "auto", "query": undefined, "savedQuery": undefined, "sort": Array [], + "viewMode": undefined, } `); }); diff --git a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts index 6fa4dda2eab190..50dab0273d4616 100644 --- a/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/apps/main/utils/get_state_defaults.ts @@ -60,6 +60,8 @@ export function getStateDefaults({ interval: 'auto', filters: cloneDeep(searchSource.getOwnField('filter')), hideChart: chartHidden ? chartHidden : undefined, + viewMode: undefined, + hideAggregatedPreview: undefined, savedQuery: undefined, } as AppState; if (savedSearch.grid) { @@ -68,6 +70,13 @@ export function getStateDefaults({ if (savedSearch.hideChart !== undefined) { defaultState.hideChart = savedSearch.hideChart; } + if (savedSearch.viewMode) { + defaultState.viewMode = savedSearch.viewMode; + } + + if (savedSearch.hideAggregatedPreview) { + defaultState.hideAggregatedPreview = savedSearch.hideAggregatedPreview; + } return defaultState; } diff --git a/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts b/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts index 584fbe14cb59ed..fa566fd485942c 100644 --- a/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/utils/persist_saved_search.ts @@ -52,6 +52,14 @@ export async function persistSavedSearch( savedSearch.hideChart = state.hideChart; } + if (state.viewMode) { + savedSearch.viewMode = state.viewMode; + } + + if (state.hideAggregatedPreview) { + savedSearch.hideAggregatedPreview = state.hideAggregatedPreview; + } + try { const id = await saveSavedSearch(savedSearch, saveOptions, services.core.savedObjects.client); if (id) { diff --git a/src/plugins/discover/public/application/components/data_visualizer_grid/data_visualizer_grid.tsx b/src/plugins/discover/public/application/components/data_visualizer_grid/data_visualizer_grid.tsx new file mode 100644 index 00000000000000..5492fac014b747 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_visualizer_grid/data_visualizer_grid.tsx @@ -0,0 +1,196 @@ +/* + * 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, { useEffect, useMemo, useRef, useState } from 'react'; +import { Filter } from '@kbn/es-query'; +import { IndexPatternField, IndexPattern, DataView, Query } from '../../../../../data/common'; +import { DiscoverServices } from '../../../build_services'; +import { + EmbeddableInput, + EmbeddableOutput, + ErrorEmbeddable, + IEmbeddable, + isErrorEmbeddable, +} from '../../../../../embeddable/public'; +import { SavedSearch } from '../../../saved_searches'; +import { GetStateReturn } from '../../apps/main/services/discover_state'; + +export interface DataVisualizerGridEmbeddableInput extends EmbeddableInput { + indexPattern: IndexPattern; + savedSearch?: SavedSearch; + query?: Query; + visibleFieldNames?: string[]; + filters?: Filter[]; + showPreviewByDefault?: boolean; + /** + * Callback to add a filter to filter bar + */ + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} +export interface DataVisualizerGridEmbeddableOutput extends EmbeddableOutput { + showDistributions?: boolean; +} + +export interface DiscoverDataVisualizerGridProps { + /** + * Determines which columns are displayed + */ + columns: string[]; + /** + * The used index pattern + */ + indexPattern: DataView; + /** + * Saved search description + */ + searchDescription?: string; + /** + * Saved search title + */ + searchTitle?: string; + /** + * Discover plugin services + */ + services: DiscoverServices; + /** + * Optional saved search + */ + savedSearch?: SavedSearch; + /** + * Optional query to update the table content + */ + query?: Query; + /** + * Filters query to update the table content + */ + filters?: Filter[]; + stateContainer?: GetStateReturn; + /** + * Callback to add a filter to filter bar + */ + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} + +export const DiscoverDataVisualizerGrid = (props: DiscoverDataVisualizerGridProps) => { + const { + services, + indexPattern, + savedSearch, + query, + columns, + filters, + stateContainer, + onAddFilter, + } = props; + const { uiSettings } = services; + + const [embeddable, setEmbeddable] = useState< + | ErrorEmbeddable + | IEmbeddable + | undefined + >(); + const embeddableRoot: React.RefObject = useRef(null); + + const showPreviewByDefault = useMemo( + () => + stateContainer ? !stateContainer.appStateContainer.getState().hideAggregatedPreview : true, + [stateContainer] + ); + + useEffect(() => { + const sub = embeddable?.getOutput$().subscribe((output: DataVisualizerGridEmbeddableOutput) => { + if (output.showDistributions !== undefined && stateContainer) { + stateContainer.setAppState({ hideAggregatedPreview: !output.showDistributions }); + } + }); + + return () => { + sub?.unsubscribe(); + }; + }, [embeddable, stateContainer]); + + useEffect(() => { + if (embeddable && !isErrorEmbeddable(embeddable)) { + // Update embeddable whenever one of the important input changes + embeddable.updateInput({ + indexPattern, + savedSearch, + query, + filters, + visibleFieldNames: columns, + onAddFilter, + }); + embeddable.reload(); + } + }, [embeddable, indexPattern, savedSearch, query, columns, filters, onAddFilter]); + + useEffect(() => { + if (showPreviewByDefault && embeddable && !isErrorEmbeddable(embeddable)) { + // Update embeddable whenever one of the important input changes + embeddable.updateInput({ + showPreviewByDefault, + }); + embeddable.reload(); + } + }, [showPreviewByDefault, uiSettings, embeddable]); + + useEffect(() => { + return () => { + // Clean up embeddable upon unmounting + embeddable?.destroy(); + }; + }, [embeddable]); + + useEffect(() => { + let unmounted = false; + const loadEmbeddable = async () => { + if (services.embeddable) { + const factory = services.embeddable.getEmbeddableFactory< + DataVisualizerGridEmbeddableInput, + DataVisualizerGridEmbeddableOutput + >('data_visualizer_grid'); + if (factory) { + // Initialize embeddable with information available at mount + const initializedEmbeddable = await factory.create({ + id: 'discover_data_visualizer_grid', + indexPattern, + savedSearch, + query, + showPreviewByDefault, + onAddFilter, + }); + if (!unmounted) { + setEmbeddable(initializedEmbeddable); + } + } + } + }; + loadEmbeddable(); + return () => { + unmounted = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [services.embeddable, showPreviewByDefault]); + + // We can only render after embeddable has already initialized + useEffect(() => { + if (embeddableRoot.current && embeddable) { + embeddable.render(embeddableRoot.current); + } + }, [embeddable, embeddableRoot, uiSettings]); + + return ( +
+ ); +}; diff --git a/src/plugins/discover/public/application/components/data_visualizer_grid/field_stats_table_embeddable.tsx b/src/plugins/discover/public/application/components/data_visualizer_grid/field_stats_table_embeddable.tsx new file mode 100644 index 00000000000000..099f45bf988ccf --- /dev/null +++ b/src/plugins/discover/public/application/components/data_visualizer_grid/field_stats_table_embeddable.tsx @@ -0,0 +1,31 @@ +/* + * 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 { I18nProvider } from '@kbn/i18n/react'; +import { + DiscoverDataVisualizerGrid, + DiscoverDataVisualizerGridProps, +} from './data_visualizer_grid'; + +export function FieldStatsTableEmbeddable(renderProps: DiscoverDataVisualizerGridProps) { + return ( + + + + ); +} diff --git a/src/plugins/discover/public/application/components/data_visualizer_grid/index.ts b/src/plugins/discover/public/application/components/data_visualizer_grid/index.ts new file mode 100644 index 00000000000000..dc85495a7c2ec8 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_visualizer_grid/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { DiscoverDataVisualizerGrid } from './data_visualizer_grid'; diff --git a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx index 89c47559d7b4cb..808962dc8319d5 100644 --- a/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/application/embeddable/saved_search_embeddable.tsx @@ -19,12 +19,12 @@ import { SEARCH_EMBEDDABLE_TYPE } from './constants'; import { APPLY_FILTER_TRIGGER, esFilters, FilterManager } from '../../../../data/public'; import { DiscoverServices } from '../../build_services'; import { - Query, - TimeRange, Filter, IndexPattern, - ISearchSource, IndexPatternField, + ISearchSource, + Query, + TimeRange, } from '../../../../data/common'; import { ElasticSearchHit } from '../doc_views/doc_views_types'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; @@ -35,6 +35,7 @@ import { DOC_TABLE_LEGACY, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, + SHOW_FIELD_STATISTICS, SORT_DEFAULT_ORDER_SETTING, } from '../../../common'; import * as columnActions from '../apps/main/components/doc_table/actions/columns'; @@ -45,6 +46,8 @@ import { DocTableProps } from '../apps/main/components/doc_table/doc_table_wrapp import { getDefaultSort } from '../apps/main/components/doc_table'; import { SortOrder } from '../apps/main/components/doc_table/components/table_header/helpers'; import { updateSearchSource } from './helpers/update_search_source'; +import { VIEW_MODE } from '../apps/main/components/view_mode_toggle'; +import { FieldStatsTableEmbeddable } from '../components/data_visualizer_grid/field_stats_table_embeddable'; export type SearchProps = Partial & Partial & { @@ -379,6 +382,28 @@ export class SavedSearchEmbeddable if (!this.searchProps) { return; } + + if ( + this.services.uiSettings.get(SHOW_FIELD_STATISTICS) === true && + this.savedSearch.viewMode === VIEW_MODE.AGGREGATED_LEVEL && + searchProps.services && + searchProps.indexPattern && + Array.isArray(searchProps.columns) + ) { + ReactDOM.render( + , + domNode + ); + return; + } const useLegacyTable = this.services.uiSettings.get(DOC_TABLE_LEGACY); const props = { searchProps, diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index ac16b6b3cc2bab..a6b175e34bd136 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -37,6 +37,7 @@ import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public'; import { FieldFormatsStart } from '../../field_formats/public'; +import { EmbeddableStart } from '../../embeddable/public'; import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public'; @@ -47,6 +48,7 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; + embeddable: EmbeddableStart; history: () => History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; @@ -83,6 +85,7 @@ export function buildServices( core, data: plugins.data, docLinks: core.docLinks, + embeddable: plugins.embeddable, theme: plugins.charts.theme, fieldFormats: plugins.fieldFormats, filterManager: plugins.data.query.filterManager, diff --git a/src/plugins/discover/public/plugin.tsx b/src/plugins/discover/public/plugin.tsx index e170e61f7ebc56..c91bcf3897e145 100644 --- a/src/plugins/discover/public/plugin.tsx +++ b/src/plugins/discover/public/plugin.tsx @@ -348,6 +348,11 @@ export class DiscoverPlugin await depsStart.data.indexPatterns.clearCache(); const { renderApp } = await import('./application'); + + // FIXME: Temporarily hide overflow-y in Discover app when Field Stats table is shown + // due to EUI bug https://github.com/elastic/eui/pull/5152 + params.element.classList.add('dscAppWrapper'); + const unmount = renderApp(params.element); return () => { unlistenParentHistory(); diff --git a/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts b/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts index 755831e7009ed9..560e16b12e5ed5 100644 --- a/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts +++ b/src/plugins/discover/public/saved_searches/get_saved_searches.test.ts @@ -101,6 +101,7 @@ describe('getSavedSearch', () => { ], "description": "description", "grid": Object {}, + "hideAggregatedPreview": undefined, "hideChart": false, "id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7", "searchSource": Object { @@ -138,6 +139,7 @@ describe('getSavedSearch', () => { ], ], "title": "test1", + "viewMode": undefined, } `); }); diff --git a/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts b/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts index 12c73e86b3dc4d..82510340f30f11 100644 --- a/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts +++ b/src/plugins/discover/public/saved_searches/saved_searches_utils.test.ts @@ -54,6 +54,7 @@ describe('saved_searches_utils', () => { ], "description": "foo", "grid": Object {}, + "hideAggregatedPreview": undefined, "hideChart": true, "id": "id", "searchSource": SearchSource { @@ -74,6 +75,7 @@ describe('saved_searches_utils', () => { "sharingSavedObjectProps": Object {}, "sort": Array [], "title": "saved search", + "viewMode": undefined, } `); }); @@ -122,6 +124,7 @@ describe('saved_searches_utils', () => { ], "description": "description", "grid": Object {}, + "hideAggregatedPreview": undefined, "hideChart": true, "kibanaSavedObjectMeta": Object { "searchSourceJSON": "{}", @@ -133,6 +136,7 @@ describe('saved_searches_utils', () => { ], ], "title": "title", + "viewMode": undefined, } `); }); diff --git a/src/plugins/discover/public/saved_searches/saved_searches_utils.ts b/src/plugins/discover/public/saved_searches/saved_searches_utils.ts index 98ab2267a875ec..064ee6afe0e997 100644 --- a/src/plugins/discover/public/saved_searches/saved_searches_utils.ts +++ b/src/plugins/discover/public/saved_searches/saved_searches_utils.ts @@ -41,6 +41,8 @@ export const fromSavedSearchAttributes = ( description: attributes.description, grid: attributes.grid, hideChart: attributes.hideChart, + viewMode: attributes.viewMode, + hideAggregatedPreview: attributes.hideAggregatedPreview, }); export const toSavedSearchAttributes = ( @@ -54,4 +56,6 @@ export const toSavedSearchAttributes = ( description: savedSearch.description ?? '', grid: savedSearch.grid ?? {}, hideChart: savedSearch.hideChart ?? false, + viewMode: savedSearch.viewMode, + hideAggregatedPreview: savedSearch.hideAggregatedPreview, }); diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index 10a6282063d38d..b3a67ea57e769e 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -8,6 +8,7 @@ import type { ISearchSource } from '../../../data/public'; import { DiscoverGridSettingsColumn } from '../application/components/discover_grid/types'; +import { VIEW_MODE } from '../application/apps/main/components/view_mode_toggle'; /** @internal **/ export interface SavedSearchAttributes { @@ -22,6 +23,8 @@ export interface SavedSearchAttributes { kibanaSavedObjectMeta: { searchSourceJSON: string; }; + viewMode?: VIEW_MODE; + hideAggregatedPreview?: boolean; } /** @internal **/ @@ -44,4 +47,6 @@ export interface SavedSearch { aliasTargetId?: string; errorJSON?: string; }; + viewMode?: VIEW_MODE; + hideAggregatedPreview?: boolean; } diff --git a/src/plugins/discover/server/saved_objects/search.ts b/src/plugins/discover/server/saved_objects/search.ts index 6a856854076129..23d9312e828970 100644 --- a/src/plugins/discover/server/saved_objects/search.ts +++ b/src/plugins/discover/server/saved_objects/search.ts @@ -32,7 +32,9 @@ export const searchSavedObjectType: SavedObjectsType = { properties: { columns: { type: 'keyword', index: false, doc_values: false }, description: { type: 'text' }, + viewMode: { type: 'keyword', index: false, doc_values: false }, hideChart: { type: 'boolean', index: false, doc_values: false }, + hideAggregatedPreview: { type: 'boolean', index: false, doc_values: false }, hits: { type: 'integer', index: false, doc_values: false }, kibanaSavedObjectMeta: { properties: { diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index d6a105bdb62630..529ba0d1beef12 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -26,6 +26,7 @@ import { SEARCH_FIELDS_FROM_SOURCE, MAX_DOC_FIELDS_DISPLAYED, SHOW_MULTIFIELDS, + SHOW_FIELD_STATISTICS, } from '../common'; export const getUiSettings: () => Record = () => ({ @@ -172,6 +173,7 @@ export const getUiSettings: () => Record = () => ({ name: 'discover:useLegacyDataGrid', }, }, + [MODIFY_COLUMNS_ON_SWITCH]: { name: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchTitle', { defaultMessage: 'Modify columns when changing data views', @@ -201,6 +203,24 @@ export const getUiSettings: () => Record = () => ({ category: ['discover'], schema: schema.boolean(), }, + [SHOW_FIELD_STATISTICS]: { + name: i18n.translate('discover.advancedSettings.discover.showFieldStatistics', { + defaultMessage: 'Show field statistics', + }), + description: i18n.translate( + 'discover.advancedSettings.discover.showFieldStatisticsDescription', + { + defaultMessage: `Enable "Field statistics" table in Discover.`, + } + ), + value: false, + category: ['discover'], + schema: schema.boolean(), + metric: { + type: METRIC_TYPE.CLICK, + name: 'discover:showFieldStatistics', + }, + }, [SHOW_MULTIFIELDS]: { name: i18n.translate('discover.advancedSettings.discover.showMultifields', { defaultMessage: 'Show multi-fields', diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts index a8a391995b0051..bf936b2ae8dbe7 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/schema.ts @@ -448,6 +448,10 @@ export const stackManagementSchema: MakeSchemaFrom = { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, }, + 'discover:showFieldStatistics': { + type: 'boolean', + _meta: { description: 'Non-default value of setting.' }, + }, 'discover:showMultiFields': { type: 'boolean', _meta: { description: 'Non-default value of setting.' }, diff --git a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts index 7ea80ffb77dda3..7575fa5d2b3f3d 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/management/types.ts @@ -31,6 +31,7 @@ export interface UsageStats { 'doc_table:legacy': boolean; 'discover:modifyColumnsOnSwitch': boolean; 'discover:searchFieldsFromSource': boolean; + 'discover:showFieldStatistics': boolean; 'discover:showMultiFields': boolean; 'discover:maxDocFieldsDisplayed': number; 'securitySolution:rulesTableRefresh': string; diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index c6724056f77a54..f9ca99a26ec198 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7689,6 +7689,12 @@ "description": "Non-default value of setting." } }, + "discover:showFieldStatistics": { + "type": "boolean", + "_meta": { + "description": "Non-default value of setting." + } + }, "discover:showMultiFields": { "type": "boolean", "_meta": { diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index a6ee65e0febb5e..a45c1a23ed3a5c 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import expect from '@kbn/expect'; import { FtrService } from '../ftr_provider_context'; export class DiscoverPageObject extends FtrService { @@ -307,6 +308,13 @@ export class DiscoverPageObject extends FtrService { return await this.testSubjects.click('collapseSideBarButton'); } + public async closeSidebar() { + await this.retry.tryForTime(2 * 1000, async () => { + await this.toggleSidebarCollapse(); + await this.testSubjects.missingOrFail('discover-sidebar'); + }); + } + public async getAllFieldNames() { const sidebar = await this.testSubjects.find('discover-sidebar'); const $ = await sidebar.parseDomContent(); @@ -545,4 +553,37 @@ export class DiscoverPageObject extends FtrService { public async clearSavedQuery() { await this.testSubjects.click('saved-query-management-clear-button'); } + + public async assertHitCount(expectedHitCount: string) { + await this.retry.tryForTime(2 * 1000, async () => { + // Close side bar to ensure Discover hit count shows + // edge case for when browser width is small + await this.closeSidebar(); + const hitCount = await this.getHitCount(); + expect(hitCount).to.eql( + expectedHitCount, + `Expected Discover hit count to be ${expectedHitCount} but got ${hitCount}.` + ); + }); + } + + public async assertViewModeToggleNotExists() { + await this.testSubjects.missingOrFail('dscViewModeToggle', { timeout: 2 * 1000 }); + } + + public async assertViewModeToggleExists() { + await this.testSubjects.existOrFail('dscViewModeToggle', { timeout: 2 * 1000 }); + } + + public async assertFieldStatsTableNotExists() { + await this.testSubjects.missingOrFail('dscFieldStatsEmbeddedContent', { timeout: 2 * 1000 }); + } + + public async clickViewModeFieldStatsButton() { + await this.retry.tryForTime(2 * 1000, async () => { + await this.testSubjects.existOrFail('dscViewModeFieldStatsButton'); + await this.testSubjects.clickWhenNotDisabled('dscViewModeFieldStatsButton'); + await this.testSubjects.existOrFail('dscFieldStatsEmbeddedContent'); + }); + } } diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap index 927d8ddb7a851b..398dc5dad2dc78 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/__snapshots__/field_type_icon.test.tsx.snap @@ -10,7 +10,7 @@ exports[`FieldTypeIcon render component when type matches a field type 1`] = ` > diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx index 2373cfe1f32841..9d803e3d4a80c6 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_type_icon/field_type_icon.tsx @@ -48,7 +48,7 @@ export const typeToEuiIconMap: Record { diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/constants.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/constants.ts new file mode 100644 index 00000000000000..26004db8fd5299 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/constants.ts @@ -0,0 +1,8 @@ +/* + * 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 DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE = 'data_visualizer_grid'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_loading_fallback.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_loading_fallback.tsx new file mode 100644 index 00000000000000..01644efd6652c2 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/embeddable_loading_fallback.tsx @@ -0,0 +1,20 @@ +/* + * 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 from 'react'; + +import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui'; + +export const EmbeddableLoading = () => { + return ( + + + + + + ); +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx new file mode 100644 index 00000000000000..f59225b1c019fc --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable.tsx @@ -0,0 +1,234 @@ +/* + * 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 { Observable, Subject } from 'rxjs'; +import { CoreStart } from 'kibana/public'; +import ReactDOM from 'react-dom'; +import React, { Suspense, useCallback, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { EuiEmptyPrompt, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; +import { Filter } from '@kbn/es-query'; +import { Required } from 'utility-types'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + Embeddable, + EmbeddableInput, + EmbeddableOutput, + IContainer, +} from '../../../../../../../../src/plugins/embeddable/public'; +import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public'; +import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants'; +import { EmbeddableLoading } from './embeddable_loading_fallback'; +import { DataVisualizerStartDependencies } from '../../../../plugin'; +import { + IndexPattern, + IndexPatternField, + Query, +} from '../../../../../../../../src/plugins/data/common'; +import { SavedSearch } from '../../../../../../../../src/plugins/discover/public'; +import { + DataVisualizerTable, + ItemIdToExpandedRowMap, +} from '../../../common/components/stats_table'; +import { FieldVisConfig } from '../../../common/components/stats_table/types'; +import { getDefaultDataVisualizerListState } from '../../components/index_data_visualizer_view/index_data_visualizer_view'; +import { DataVisualizerTableState } from '../../../../../common'; +import { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state'; +import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row'; +import { useDataVisualizerGridData } from './use_data_visualizer_grid_data'; + +export type DataVisualizerGridEmbeddableServices = [CoreStart, DataVisualizerStartDependencies]; +export interface DataVisualizerGridEmbeddableInput extends EmbeddableInput { + indexPattern: IndexPattern; + savedSearch?: SavedSearch; + query?: Query; + visibleFieldNames?: string[]; + filters?: Filter[]; + showPreviewByDefault?: boolean; + /** + * Callback to add a filter to filter bar + */ + onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} +export type DataVisualizerGridEmbeddableOutput = EmbeddableOutput; + +export type IDataVisualizerGridEmbeddable = typeof DataVisualizerGridEmbeddable; + +const restorableDefaults = getDefaultDataVisualizerListState(); + +export const EmbeddableWrapper = ({ + input, + onOutputChange, +}: { + input: DataVisualizerGridEmbeddableInput; + onOutputChange?: (ouput: any) => void; +}) => { + const [dataVisualizerListState, setDataVisualizerListState] = + useState>(restorableDefaults); + + const onTableChange = useCallback( + (update: DataVisualizerTableState) => { + setDataVisualizerListState({ ...dataVisualizerListState, ...update }); + if (onOutputChange) { + onOutputChange(update); + } + }, + [dataVisualizerListState, onOutputChange] + ); + const { configs, searchQueryLanguage, searchString, extendedColumns, loaded } = + useDataVisualizerGridData(input, dataVisualizerListState); + const getItemIdToExpandedRowMap = useCallback( + function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap { + return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => { + const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName); + if (item !== undefined) { + m[fieldName] = ( + + ); + } + return m; + }, {} as ItemIdToExpandedRowMap); + }, + [input, searchQueryLanguage, searchString] + ); + + if ( + loaded && + (configs.length === 0 || + // FIXME: Configs might have a placeholder document count stats field + // This will be removed in the future + (configs.length === 1 && configs[0].fieldName === undefined)) + ) { + return ( +
+ + + + + +
+ ); + } + return ( + + items={configs} + pageState={dataVisualizerListState} + updatePageState={onTableChange} + getItemIdToExpandedRowMap={getItemIdToExpandedRowMap} + extendedColumns={extendedColumns} + showPreviewByDefault={input?.showPreviewByDefault} + onChange={onOutputChange} + /> + ); +}; + +export const IndexDataVisualizerViewWrapper = (props: { + id: string; + embeddableContext: InstanceType; + embeddableInput: Readonly>; + onOutputChange?: (output: any) => void; +}) => { + const { embeddableInput, onOutputChange } = props; + + const input = useObservable(embeddableInput); + if (input && input.indexPattern) { + return ; + } else { + return ( + + + + } + body={ +

+ +

+ } + /> + ); + } +}; +export class DataVisualizerGridEmbeddable extends Embeddable< + DataVisualizerGridEmbeddableInput, + DataVisualizerGridEmbeddableOutput +> { + private node?: HTMLElement; + private reload$ = new Subject(); + public readonly type: string = DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE; + + constructor( + initialInput: DataVisualizerGridEmbeddableInput, + public services: DataVisualizerGridEmbeddableServices, + parent?: IContainer + ) { + super(initialInput, {}, parent); + } + + public render(node: HTMLElement) { + super.render(node); + this.node = node; + + const I18nContext = this.services[0].i18n.Context; + + ReactDOM.render( + + + }> + this.updateOutput(output)} + /> + + + , + node + ); + } + + public destroy() { + super.destroy(); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + } + + public reload() { + this.reload$.next(); + } + + public supportedTriggers() { + return []; + } +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable_factory.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable_factory.tsx new file mode 100644 index 00000000000000..08ddc2d5fe3c22 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/grid_embeddable_factory.tsx @@ -0,0 +1,70 @@ +/* + * 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 { StartServicesAccessor } from 'kibana/public'; +import { + EmbeddableFactoryDefinition, + IContainer, +} from '../../../../../../../../src/plugins/embeddable/public'; +import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants'; +import { + DataVisualizerGridEmbeddableInput, + DataVisualizerGridEmbeddableServices, +} from './grid_embeddable'; +import { DataVisualizerPluginStart, DataVisualizerStartDependencies } from '../../../../plugin'; + +export class DataVisualizerGridEmbeddableFactory + implements EmbeddableFactoryDefinition +{ + public readonly type = DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE; + + public readonly grouping = [ + { + id: 'data_visualizer_grid', + getDisplayName: () => 'Data Visualizer Grid', + }, + ]; + + constructor( + private getStartServices: StartServicesAccessor< + DataVisualizerStartDependencies, + DataVisualizerPluginStart + > + ) {} + + public async isEditable() { + return false; + } + + public canCreateNew() { + return false; + } + + public getDisplayName() { + return i18n.translate('xpack.dataVisualizer.index.components.grid.displayName', { + defaultMessage: 'Data visualizer grid', + }); + } + + public getDescription() { + return i18n.translate('xpack.dataVisualizer.index.components.grid.description', { + defaultMessage: 'Visualize data', + }); + } + + private async getServices(): Promise { + const [coreStart, pluginsStart] = await this.getStartServices(); + return [coreStart, pluginsStart]; + } + + public async create(initialInput: DataVisualizerGridEmbeddableInput, parent?: IContainer) { + const [coreStart, pluginsStart] = await this.getServices(); + const { DataVisualizerGridEmbeddable } = await import('./grid_embeddable'); + return new DataVisualizerGridEmbeddable(initialInput, [coreStart, pluginsStart], parent); + } +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/index.ts new file mode 100644 index 00000000000000..91ca8e1633eb90 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { DataVisualizerGridEmbeddable } from './grid_embeddable'; +export { DataVisualizerGridEmbeddableFactory } from './grid_embeddable_factory'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/use_data_visualizer_grid_data.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/use_data_visualizer_grid_data.ts new file mode 100644 index 00000000000000..fc0fc7a2134b44 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/grid_embeddable/use_data_visualizer_grid_data.ts @@ -0,0 +1,587 @@ +/* + * 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 { Required } from 'utility-types'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { merge } from 'rxjs'; +import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_table/table_types'; +import { i18n } from '@kbn/i18n'; +import { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state'; +import { useDataVisualizerKibana } from '../../../kibana_context'; +import { getEsQueryFromSavedSearch } from '../../utils/saved_search_utils'; +import { MetricFieldsStats } from '../../../common/components/stats_table/components/field_count_stats'; +import { DataLoader } from '../../data_loader/data_loader'; +import { useTimefilter } from '../../hooks/use_time_filter'; +import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service'; +import { TimeBuckets } from '../../services/time_buckets'; +import { + DataViewField, + KBN_FIELD_TYPES, + UI_SETTINGS, +} from '../../../../../../../../src/plugins/data/common'; +import { extractErrorProperties } from '../../utils/error_utils'; +import { FieldVisConfig } from '../../../common/components/stats_table/types'; +import { FieldRequestConfig, JOB_FIELD_TYPES } from '../../../../../common'; +import { kbnTypeToJobType } from '../../../common/util/field_types_utils'; +import { getActions } from '../../../common/components/field_data_row/action_menu'; +import { DataVisualizerGridEmbeddableInput } from './grid_embeddable'; +import { getDefaultPageState } from '../../components/index_data_visualizer_view/index_data_visualizer_view'; + +const defaults = getDefaultPageState(); + +export const useDataVisualizerGridData = ( + input: DataVisualizerGridEmbeddableInput, + dataVisualizerListState: Required +) => { + const { services } = useDataVisualizerKibana(); + const { notifications, uiSettings } = services; + const { toasts } = notifications; + const { samplerShardSize, visibleFieldTypes, showEmptyFields } = dataVisualizerListState; + + const [lastRefresh, setLastRefresh] = useState(0); + + const { + currentSavedSearch, + currentIndexPattern, + currentQuery, + currentFilters, + visibleFieldNames, + } = useMemo( + () => ({ + currentSavedSearch: input?.savedSearch, + currentIndexPattern: input.indexPattern, + currentQuery: input?.query, + visibleFieldNames: input?.visibleFieldNames ?? [], + currentFilters: input?.filters, + }), + [input] + ); + + const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => { + const searchData = getEsQueryFromSavedSearch({ + indexPattern: currentIndexPattern, + uiSettings, + savedSearch: currentSavedSearch, + query: currentQuery, + filters: currentFilters, + }); + + if (searchData === undefined || dataVisualizerListState.searchString !== '') { + return { + searchQuery: dataVisualizerListState.searchQuery, + searchString: dataVisualizerListState.searchString, + searchQueryLanguage: dataVisualizerListState.searchQueryLanguage, + }; + } else { + return { + searchQuery: searchData.searchQuery, + searchString: searchData.searchString, + searchQueryLanguage: searchData.queryLanguage, + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentSavedSearch, + currentIndexPattern, + dataVisualizerListState, + currentQuery, + currentFilters, + ]); + + const [overallStats, setOverallStats] = useState(defaults.overallStats); + + const [documentCountStats, setDocumentCountStats] = useState(defaults.documentCountStats); + const [metricConfigs, setMetricConfigs] = useState(defaults.metricConfigs); + const [metricsLoaded, setMetricsLoaded] = useState(defaults.metricsLoaded); + const [metricsStats, setMetricsStats] = useState(); + + const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs); + const [nonMetricsLoaded, setNonMetricsLoaded] = useState(defaults.nonMetricsLoaded); + + const dataLoader = useMemo( + () => new DataLoader(currentIndexPattern, toasts), + [currentIndexPattern, toasts] + ); + + const timefilter = useTimefilter({ + timeRangeSelector: currentIndexPattern?.timeFieldName !== undefined, + autoRefreshSelector: true, + }); + + useEffect(() => { + const timeUpdateSubscription = merge( + timefilter.getTimeUpdate$(), + dataVisualizerRefresh$ + ).subscribe(() => { + setLastRefresh(Date.now()); + }); + return () => { + timeUpdateSubscription.unsubscribe(); + }; + }); + + const getTimeBuckets = useCallback(() => { + return new TimeBuckets({ + [UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + [UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); + + const indexPatternFields: DataViewField[] = useMemo( + () => currentIndexPattern.fields, + [currentIndexPattern] + ); + + async function loadOverallStats() { + const tf = timefilter as any; + let earliest; + let latest; + + const activeBounds = tf.getActiveBounds(); + + if (currentIndexPattern.timeFieldName !== undefined && activeBounds === undefined) { + return; + } + + if (currentIndexPattern.timeFieldName !== undefined) { + earliest = activeBounds.min.valueOf(); + latest = activeBounds.max.valueOf(); + } + + try { + const allStats = await dataLoader.loadOverallData( + searchQuery, + samplerShardSize, + earliest, + latest + ); + // Because load overall stats perform queries in batches + // there could be multiple errors + if (Array.isArray(allStats.errors) && allStats.errors.length > 0) { + allStats.errors.forEach((err: any) => { + dataLoader.displayError(extractErrorProperties(err)); + }); + } + setOverallStats(allStats); + } catch (err) { + dataLoader.displayError(err.body ?? err); + } + } + + const createMetricCards = useCallback(() => { + const configs: FieldVisConfig[] = []; + const aggregatableExistsFields: any[] = overallStats.aggregatableExistsFields || []; + + const allMetricFields = indexPatternFields.filter((f) => { + return ( + f.type === KBN_FIELD_TYPES.NUMBER && + f.displayName !== undefined && + dataLoader.isDisplayField(f.displayName) === true + ); + }); + const metricExistsFields = allMetricFields.filter((f) => { + return aggregatableExistsFields.find((existsF) => { + return existsF.fieldName === f.spec.name; + }); + }); + + // Add a config for 'document count', identified by no field name if indexpattern is time based. + if (currentIndexPattern.timeFieldName !== undefined) { + configs.push({ + type: JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + loading: true, + aggregatable: true, + }); + } + + if (metricsLoaded === false) { + setMetricsLoaded(true); + return; + } + + let aggregatableFields: any[] = overallStats.aggregatableExistsFields; + if (allMetricFields.length !== metricExistsFields.length && metricsLoaded === true) { + aggregatableFields = aggregatableFields.concat(overallStats.aggregatableNotExistsFields); + } + + const metricFieldsToShow = + metricsLoaded === true && showEmptyFields === true ? allMetricFields : metricExistsFields; + + metricFieldsToShow.forEach((field) => { + const fieldData = aggregatableFields.find((f) => { + return f.fieldName === field.spec.name; + }); + + const metricConfig: FieldVisConfig = { + ...(fieldData ? fieldData : {}), + fieldFormat: currentIndexPattern.getFormatterForField(field), + type: JOB_FIELD_TYPES.NUMBER, + loading: true, + aggregatable: true, + deletable: field.runtimeField !== undefined, + }; + if (field.displayName !== metricConfig.fieldName) { + metricConfig.displayName = field.displayName; + } + + configs.push(metricConfig); + }); + + setMetricsStats({ + totalMetricFieldsCount: allMetricFields.length, + visibleMetricsCount: metricFieldsToShow.length, + }); + setMetricConfigs(configs); + }, [ + currentIndexPattern, + dataLoader, + indexPatternFields, + metricsLoaded, + overallStats, + showEmptyFields, + ]); + + const createNonMetricCards = useCallback(() => { + const allNonMetricFields = indexPatternFields.filter((f) => { + return ( + f.type !== KBN_FIELD_TYPES.NUMBER && + f.displayName !== undefined && + dataLoader.isDisplayField(f.displayName) === true + ); + }); + // Obtain the list of all non-metric fields which appear in documents + // (aggregatable or not aggregatable). + const populatedNonMetricFields: any[] = []; // Kibana index pattern non metric fields. + let nonMetricFieldData: any[] = []; // Basic non metric field data loaded from requesting overall stats. + const aggregatableExistsFields: any[] = overallStats.aggregatableExistsFields || []; + const nonAggregatableExistsFields: any[] = overallStats.nonAggregatableExistsFields || []; + + allNonMetricFields.forEach((f) => { + const checkAggregatableField = aggregatableExistsFields.find( + (existsField) => existsField.fieldName === f.spec.name + ); + + if (checkAggregatableField !== undefined) { + populatedNonMetricFields.push(f); + nonMetricFieldData.push(checkAggregatableField); + } else { + const checkNonAggregatableField = nonAggregatableExistsFields.find( + (existsField) => existsField.fieldName === f.spec.name + ); + + if (checkNonAggregatableField !== undefined) { + populatedNonMetricFields.push(f); + nonMetricFieldData.push(checkNonAggregatableField); + } + } + }); + + if (nonMetricsLoaded === false) { + setNonMetricsLoaded(true); + return; + } + + if (allNonMetricFields.length !== nonMetricFieldData.length && showEmptyFields === true) { + // Combine the field data obtained from Elasticsearch into a single array. + nonMetricFieldData = nonMetricFieldData.concat( + overallStats.aggregatableNotExistsFields, + overallStats.nonAggregatableNotExistsFields + ); + } + + const nonMetricFieldsToShow = showEmptyFields ? allNonMetricFields : populatedNonMetricFields; + + const configs: FieldVisConfig[] = []; + + nonMetricFieldsToShow.forEach((field) => { + const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name); + + const nonMetricConfig = { + ...(fieldData ? fieldData : {}), + fieldFormat: currentIndexPattern.getFormatterForField(field), + aggregatable: field.aggregatable, + scripted: field.scripted, + loading: fieldData?.existsInDocs, + deletable: field.runtimeField !== undefined, + }; + + // Map the field type from the Kibana index pattern to the field type + // used in the data visualizer. + const dataVisualizerType = kbnTypeToJobType(field); + if (dataVisualizerType !== undefined) { + nonMetricConfig.type = dataVisualizerType; + } else { + // Add a flag to indicate that this is one of the 'other' Kibana + // field types that do not yet have a specific card type. + nonMetricConfig.type = field.type; + nonMetricConfig.isUnsupportedType = true; + } + + if (field.displayName !== nonMetricConfig.fieldName) { + nonMetricConfig.displayName = field.displayName; + } + + configs.push(nonMetricConfig); + }); + + setNonMetricConfigs(configs); + }, [ + currentIndexPattern, + dataLoader, + indexPatternFields, + nonMetricsLoaded, + overallStats, + showEmptyFields, + ]); + + async function loadMetricFieldStats() { + // Only request data for fields that exist in documents. + if (metricConfigs.length === 0) { + return; + } + + const configsToLoad = metricConfigs.filter( + (config) => config.existsInDocs === true && config.loading === true + ); + if (configsToLoad.length === 0) { + return; + } + + // Pass the field name, type and cardinality in the request. + // Top values will be obtained on a sample if cardinality > 100000. + const existMetricFields: FieldRequestConfig[] = configsToLoad.map((config) => { + const props = { fieldName: config.fieldName, type: config.type, cardinality: 0 }; + if (config.stats !== undefined && config.stats.cardinality !== undefined) { + props.cardinality = config.stats.cardinality; + } + return props; + }); + + // Obtain the interval to use for date histogram aggregations + // (such as the document count chart). Aim for 75 bars. + const buckets = getTimeBuckets(); + + const tf = timefilter as any; + let earliest: number | undefined; + let latest: number | undefined; + if (currentIndexPattern.timeFieldName !== undefined) { + earliest = tf.getActiveBounds().min.valueOf(); + latest = tf.getActiveBounds().max.valueOf(); + } + + const bounds = tf.getActiveBounds(); + const BAR_TARGET = 75; + buckets.setInterval('auto'); + buckets.setBounds(bounds); + buckets.setBarTarget(BAR_TARGET); + const aggInterval = buckets.getInterval(); + + try { + const metricFieldStats = await dataLoader.loadFieldStats( + searchQuery, + samplerShardSize, + earliest, + latest, + existMetricFields, + aggInterval.asMilliseconds() + ); + + // Add the metric stats to the existing stats in the corresponding config. + const configs: FieldVisConfig[] = []; + metricConfigs.forEach((config) => { + const configWithStats = { ...config }; + if (config.fieldName !== undefined) { + configWithStats.stats = { + ...configWithStats.stats, + ...metricFieldStats.find( + (fieldStats: any) => fieldStats.fieldName === config.fieldName + ), + }; + configWithStats.loading = false; + configs.push(configWithStats); + } else { + // Document count card. + configWithStats.stats = metricFieldStats.find( + (fieldStats: any) => fieldStats.fieldName === undefined + ); + + if (configWithStats.stats !== undefined) { + // Add earliest / latest of timefilter for setting x axis domain. + configWithStats.stats.timeRangeEarliest = earliest; + configWithStats.stats.timeRangeLatest = latest; + } + setDocumentCountStats(configWithStats); + } + }); + + setMetricConfigs(configs); + } catch (err) { + dataLoader.displayError(err); + } + } + + async function loadNonMetricFieldStats() { + // Only request data for fields that exist in documents. + if (nonMetricConfigs.length === 0) { + return; + } + + const configsToLoad = nonMetricConfigs.filter( + (config) => config.existsInDocs === true && config.loading === true + ); + if (configsToLoad.length === 0) { + return; + } + + // Pass the field name, type and cardinality in the request. + // Top values will be obtained on a sample if cardinality > 100000. + const existNonMetricFields: FieldRequestConfig[] = configsToLoad.map((config) => { + const props = { fieldName: config.fieldName, type: config.type, cardinality: 0 }; + if (config.stats !== undefined && config.stats.cardinality !== undefined) { + props.cardinality = config.stats.cardinality; + } + return props; + }); + + const tf = timefilter as any; + let earliest; + let latest; + if (currentIndexPattern.timeFieldName !== undefined) { + earliest = tf.getActiveBounds().min.valueOf(); + latest = tf.getActiveBounds().max.valueOf(); + } + + try { + const nonMetricFieldStats = await dataLoader.loadFieldStats( + searchQuery, + samplerShardSize, + earliest, + latest, + existNonMetricFields + ); + + // Add the field stats to the existing stats in the corresponding config. + const configs: FieldVisConfig[] = []; + nonMetricConfigs.forEach((config) => { + const configWithStats = { ...config }; + if (config.fieldName !== undefined) { + configWithStats.stats = { + ...configWithStats.stats, + ...nonMetricFieldStats.find( + (fieldStats: any) => fieldStats.fieldName === config.fieldName + ), + }; + } + configWithStats.loading = false; + configs.push(configWithStats); + }); + + setNonMetricConfigs(configs); + } catch (err) { + dataLoader.displayError(err); + } + } + + useEffect(() => { + loadOverallStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, samplerShardSize, lastRefresh]); + + useEffect(() => { + createMetricCards(); + createNonMetricCards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overallStats, showEmptyFields]); + + useEffect(() => { + loadMetricFieldStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metricConfigs]); + + useEffect(() => { + loadNonMetricFieldStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nonMetricConfigs]); + + useEffect(() => { + createMetricCards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [metricsLoaded]); + + useEffect(() => { + createNonMetricCards(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nonMetricsLoaded]); + + const configs = useMemo(() => { + let combinedConfigs = [...nonMetricConfigs, ...metricConfigs]; + if (visibleFieldTypes && visibleFieldTypes.length > 0) { + combinedConfigs = combinedConfigs.filter( + (config) => visibleFieldTypes.findIndex((field) => field === config.type) > -1 + ); + } + if (visibleFieldNames && visibleFieldNames.length > 0) { + combinedConfigs = combinedConfigs.filter( + (config) => visibleFieldNames.findIndex((field) => field === config.fieldName) > -1 + ); + } + + return combinedConfigs; + }, [nonMetricConfigs, metricConfigs, visibleFieldTypes, visibleFieldNames]); + + // Some actions open up fly-out or popup + // This variable is used to keep track of them and clean up when unmounting + const actionFlyoutRef = useRef<() => void | undefined>(); + useEffect(() => { + const ref = actionFlyoutRef; + return () => { + // Clean up any of the flyout/editor opened from the actions + if (ref.current) { + ref.current(); + } + }; + }, []); + + // Inject custom action column for the index based visualizer + // Hide the column completely if no access to any of the plugins + const extendedColumns = useMemo(() => { + const actions = getActions( + input.indexPattern, + { lens: services.lens }, + { + searchQueryLanguage, + searchString, + }, + actionFlyoutRef + ); + if (!Array.isArray(actions) || actions.length < 1) return; + + const actionColumn: EuiTableActionsColumnType = { + name: i18n.translate('xpack.dataVisualizer.index.dataGrid.actionsColumnLabel', { + defaultMessage: 'Actions', + }), + actions, + width: '70px', + }; + + return [actionColumn]; + }, [input.indexPattern, services, searchQueryLanguage, searchString]); + + return { + configs, + searchQueryLanguage, + searchString, + searchQuery, + extendedColumns, + documentCountStats, + metricsStats, + loaded: metricsLoaded && nonMetricsLoaded, + }; +}; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/index.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/index.ts new file mode 100644 index 00000000000000..add99a8d2501d5 --- /dev/null +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/embeddables/index.ts @@ -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 { CoreSetup } from 'kibana/public'; +import { EmbeddableSetup } from '../../../../../../../src/plugins/embeddable/public'; +import { DataVisualizerGridEmbeddableFactory } from './grid_embeddable/grid_embeddable_factory'; +import { DataVisualizerPluginStart, DataVisualizerStartDependencies } from '../../../plugin'; + +export function registerEmbeddables( + embeddable: EmbeddableSetup, + core: CoreSetup +) { + const dataVisualizerGridEmbeddableFactory = new DataVisualizerGridEmbeddableFactory( + core.getStartServices + ); + embeddable.registerEmbeddableFactory( + dataVisualizerGridEmbeddableFactory.type, + dataVisualizerGridEmbeddableFactory + ); +} diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index a474ed3521580e..83e013703c1fcf 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -9,7 +9,6 @@ import React, { FC, useCallback, useEffect, useState } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; import { parse, stringify } from 'query-string'; import { isEqual } from 'lodash'; -// @ts-ignore import { encode } from 'rison-node'; import { SimpleSavedObject } from 'kibana/public'; import { i18n } from '@kbn/i18n'; @@ -29,7 +28,7 @@ import { isRisonSerializationRequired, } from '../common/util/url_state'; import { useDataVisualizerKibana } from '../kibana_context'; -import { IndexPattern } from '../../../../../../src/plugins/data/common'; +import { DataView } from '../../../../../../src/plugins/data/common'; import { ResultLink } from '../common/components/results_links'; export type IndexDataVisualizerSpec = typeof IndexDataVisualizer; @@ -51,9 +50,7 @@ export const DataVisualizerUrlStateContextProvider: FC( - undefined - ); + const [currentIndexPattern, setCurrentIndexPattern] = useState(undefined); const [currentSavedSearch, setCurrentSavedSearch] = useState | null>( null ); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts index c26a668bd04ab8..aab67d0b52aecd 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/locator/locator.ts @@ -4,8 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -// @ts-ignore import { encode } from 'rison-node'; import { stringify } from 'query-string'; import { SerializableRecord } from '@kbn/utility-types'; diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts index 43d815f6e9d411..ad3229676b31b6 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.test.ts @@ -6,7 +6,7 @@ */ import { - getQueryFromSavedSearch, + getQueryFromSavedSearchObject, createMergedEsQuery, getEsQueryFromSavedSearch, } from './saved_search_utils'; @@ -82,9 +82,9 @@ const kqlSavedSearch: SavedSearch = { }, }; -describe('getQueryFromSavedSearch()', () => { +describe('getQueryFromSavedSearchObject()', () => { it('should return parsed searchSourceJSON with query and filter', () => { - expect(getQueryFromSavedSearch(luceneSavedSearchObj)).toEqual({ + expect(getQueryFromSavedSearchObject(luceneSavedSearchObj)).toEqual({ filter: [ { $state: { store: 'appState' }, @@ -106,7 +106,7 @@ describe('getQueryFromSavedSearch()', () => { query: { language: 'lucene', query: 'responsetime:>50' }, version: true, }); - expect(getQueryFromSavedSearch(kqlSavedSearch)).toEqual({ + expect(getQueryFromSavedSearchObject(kqlSavedSearch)).toEqual({ filter: [ { $state: { store: 'appState' }, @@ -130,7 +130,7 @@ describe('getQueryFromSavedSearch()', () => { }); }); it('should return undefined if invalid searchSourceJSON', () => { - expect(getQueryFromSavedSearch(luceneInvalidSavedSearchObj)).toEqual(undefined); + expect(getQueryFromSavedSearchObject(luceneInvalidSavedSearchObj)).toEqual(undefined); }); }); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts index 80a2069aab1a88..1401b1038b8f24 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/utils/saved_search_utils.ts @@ -16,17 +16,31 @@ import { Filter, } from '@kbn/es-query'; import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types'; -import { IndexPattern } from '../../../../../../../src/plugins/data/common'; +import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/common'; import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../types/combined_query'; import { SavedSearch } from '../../../../../../../src/plugins/discover/public'; import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common'; import { FilterManager } from '../../../../../../../src/plugins/data/public'; +const DEFAULT_QUERY = { + bool: { + must: [ + { + match_all: {}, + }, + ], + }, +}; + +export function getDefaultQuery() { + return cloneDeep(DEFAULT_QUERY); +} + /** * Parse the stringified searchSourceJSON * from a saved search or saved search object */ -export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject | SavedSearch) { +export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObject | SavedSearch) { const search = isSavedSearchSavedObject(savedSearch) ? savedSearch?.attributes?.kibanaSavedObjectMeta : // @ts-expect-error kibanaSavedObjectMeta does exist @@ -69,20 +83,22 @@ export function createMergedEsQuery( if (query.query !== '') { combinedQuery = toElasticsearchQuery(ast, indexPattern); } - const filterQuery = buildQueryFromFilters(filters, indexPattern); + if (combinedQuery.bool !== undefined) { + const filterQuery = buildQueryFromFilters(filters, indexPattern); - if (Array.isArray(combinedQuery.bool.filter) === false) { - combinedQuery.bool.filter = - combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; - } + if (Array.isArray(combinedQuery.bool.filter) === false) { + combinedQuery.bool.filter = + combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter]; + } - if (Array.isArray(combinedQuery.bool.must_not) === false) { - combinedQuery.bool.must_not = - combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; - } + if (Array.isArray(combinedQuery.bool.must_not) === false) { + combinedQuery.bool.must_not = + combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not]; + } - combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; - combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; + combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter]; + combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not]; + } } else { combinedQuery = buildEsQuery( indexPattern, @@ -115,10 +131,31 @@ export function getEsQueryFromSavedSearch({ }) { if (!indexPattern || !savedSearch) return; - const savedSearchData = getQueryFromSavedSearch(savedSearch); const userQuery = query; const userFilters = filters; + // If saved search has a search source with nested parent + // e.g. a search coming from Dashboard saved search embeddable + // which already combines both the saved search's original query/filters and the Dashboard's + // then no need to process any further + if ( + savedSearch && + 'searchSource' in savedSearch && + savedSearch?.searchSource instanceof SearchSource && + savedSearch.searchSource.getParent() !== undefined && + userQuery + ) { + return { + searchQuery: savedSearch.searchSource.getSearchRequestBody()?.query ?? getDefaultQuery(), + searchString: userQuery.query, + queryLanguage: userQuery.language as SearchQueryLanguage, + }; + } + + // If saved search is an json object with the original query and filter + // retrieve the parsed query and filter + const savedSearchData = getQueryFromSavedSearchObject(savedSearch); + // If no saved search available, use user's query and filters if (!savedSearchData && userQuery) { if (filterManager && userFilters) filterManager.setFilters(userFilters); @@ -137,7 +174,8 @@ export function getEsQueryFromSavedSearch({ }; } - // If saved search available, merge saved search with latest user query or filters differ from extracted saved search data + // If saved search available, merge saved search with latest user query or filters + // which might differ from extracted saved search data if (savedSearchData) { const currentQuery = userQuery ?? savedSearchData?.query; const currentFilters = userFilters ?? savedSearchData?.filter; @@ -158,17 +196,3 @@ export function getEsQueryFromSavedSearch({ }; } } - -const DEFAULT_QUERY = { - bool: { - must: [ - { - match_all: {}, - }, - ], - }, -}; - -export function getDefaultQuery() { - return cloneDeep(DEFAULT_QUERY); -} diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 112294f4b246fe..df1a5ea406d768 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -6,7 +6,7 @@ */ import { CoreSetup, CoreStart } from 'kibana/public'; -import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { SharePluginStart } from '../../../../src/plugins/share/public'; import { Plugin } from '../../../../src/core/public'; @@ -21,9 +21,11 @@ import type { IndexPatternFieldEditorStart } from '../../../../src/plugins/index import { getFileDataVisualizerComponent, getIndexDataVisualizerComponent } from './api'; import { getMaxBytesFormatted } from './application/common/util/get_max_bytes'; import { registerHomeAddData, registerHomeFeatureCatalogue } from './register_home'; +import { registerEmbeddables } from './application/index_data_visualizer/embeddables'; export interface DataVisualizerSetupDependencies { home?: HomePublicPluginSetup; + embeddable: EmbeddableSetup; } export interface DataVisualizerStartDependencies { data: DataPublicPluginStart; @@ -56,6 +58,9 @@ export class DataVisualizerPlugin registerHomeAddData(plugins.home); registerHomeFeatureCatalogue(plugins.home); } + if (plugins.embeddable) { + registerEmbeddables(plugins.embeddable, core); + } } public start(core: CoreStart, plugins: DataVisualizerStartDependencies) { diff --git a/x-pack/plugins/data_visualizer/tsconfig.json b/x-pack/plugins/data_visualizer/tsconfig.json index 3b424ef8b9f658..df41fdbd62663f 100644 --- a/x-pack/plugins/data_visualizer/tsconfig.json +++ b/x-pack/plugins/data_visualizer/tsconfig.json @@ -6,9 +6,18 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "server/**/*"], + "include": [ + "../../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "types/**/*" + ], "references": [ { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, { "path": "../../../src/plugins/custom_integrations/tsconfig.json" }, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts index 324db0d6b2ad48..41973b5ec2d017 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts @@ -15,7 +15,7 @@ import { import { estypes } from '@elastic/elasticsearch'; import { useMlContext } from '../../../../../contexts/ml'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search'; -import { getQueryFromSavedSearch } from '../../../../../util/index_utils'; +import { getQueryFromSavedSearchObject } from '../../../../../util/index_utils'; // `undefined` is used for a non-initialized state // `null` is set if no saved search is used @@ -40,7 +40,7 @@ export function useSavedSearch() { let qryString; if (currentSavedSearch !== null) { - const { query } = getQueryFromSavedSearch(currentSavedSearch); + const { query } = getQueryFromSavedSearchObject(currentSavedSearch); const queryLanguage = query.language; qryString = query.query; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts index 5eae60900e09ff..ebab3769fbe578 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/utils/new_job_utils.ts @@ -18,7 +18,7 @@ import { IUiSettingsClient } from 'kibana/public'; import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/public'; import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search'; import { SavedSearchSavedObject } from '../../../../../common/types/kibana'; -import { getQueryFromSavedSearch } from '../../../util/index_utils'; +import { getQueryFromSavedSearchObject } from '../../../util/index_utils'; // Provider for creating the items used for searching and job creation. @@ -52,7 +52,7 @@ export function createSearchItems( let combinedQuery: any = getDefaultDatafeedQuery(); if (savedSearch !== null) { - const data = getQueryFromSavedSearch(savedSearch); + const data = getQueryFromSavedSearchObject(savedSearch); query = data.query; const filter = data.filter; diff --git a/x-pack/plugins/ml/public/application/util/index_utils.ts b/x-pack/plugins/ml/public/application/util/index_utils.ts index e4c18308bf0171..b105761e5ebcff 100644 --- a/x-pack/plugins/ml/public/application/util/index_utils.ts +++ b/x-pack/plugins/ml/public/application/util/index_utils.ts @@ -80,7 +80,7 @@ export async function getIndexPatternAndSavedSearch(savedSearchId: string) { return resp; } -export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject) { +export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObject) { const search = savedSearch.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string }; return JSON.parse(search.searchSourceJSON) as { query: Query; diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index.ts b/x-pack/test/functional/apps/ml/data_visualizer/index.ts index 3e6b644a0b494a..c1e5d0b4b6aaec 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index.ts @@ -9,9 +9,10 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('data visualizer', function () { - this.tags(['skipFirefox']); + this.tags(['skipFirefox', 'mlqa']); loadTestFile(require.resolve('./index_data_visualizer')); + loadTestFile(require.resolve('./index_data_visualizer_grid_in_discover')); loadTestFile(require.resolve('./index_data_visualizer_actions_panel')); loadTestFile(require.resolve('./index_data_visualizer_index_pattern_management')); loadTestFile(require.resolve('./file_data_visualizer')); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts index 542f7f3116c944..ff0d489293682a 100644 --- a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer.ts @@ -6,374 +6,18 @@ */ import { FtrProviderContext } from '../../../ftr_provider_context'; -import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types'; -import { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types'; - -interface MetricFieldVisConfig extends FieldVisConfig { - statsMaxDecimalPlaces: number; - docCountFormatted: string; - topValuesCount: number; - viewableInLens: boolean; -} - -interface NonMetricFieldVisConfig extends FieldVisConfig { - docCountFormatted: string; - exampleCount: number; - viewableInLens: boolean; -} - -interface TestData { - suiteTitle: string; - sourceIndexOrSavedSearch: string; - fieldNameFilters: string[]; - fieldTypeFilters: string[]; - rowsPerPage?: 10 | 25 | 50; - sampleSizeValidations: Array<{ - size: number; - expected: { field: string; docCountFormatted: string }; - }>; - expected: { - totalDocCountFormatted: string; - metricFields?: MetricFieldVisConfig[]; - nonMetricFields?: NonMetricFieldVisConfig[]; - emptyFields: string[]; - visibleMetricFieldsCount: number; - totalMetricFieldsCount: number; - populatedFieldsCount: number; - totalFieldsCount: number; - fieldNameFiltersResultCount: number; - fieldTypeFiltersResultCount: number; - }; -} +import { TestData, MetricFieldVisConfig } from './types'; +import { + farequoteDataViewTestData, + farequoteKQLSearchTestData, + farequoteLuceneSearchTestData, + sampleLogTestData, +} from './index_test_data'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - const farequoteDataViewTestData: TestData = { - suiteTitle: 'data view', - sourceIndexOrSavedSearch: 'ft_farequote', - fieldNameFilters: ['airline', '@timestamp'], - fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD], - sampleSizeValidations: [ - { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, - { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, - ], - expected: { - totalDocCountFormatted: '86,274', - metricFields: [ - { - fieldName: 'responsetime', - type: ML_JOB_FIELD_TYPES.NUMBER, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - statsMaxDecimalPlaces: 3, - topValuesCount: 10, - viewableInLens: true, - }, - ], - nonMetricFields: [ - { - fieldName: '@timestamp', - type: ML_JOB_FIELD_TYPES.DATE, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - exampleCount: 2, - viewableInLens: true, - }, - { - fieldName: '@version', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: '@version.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'airline', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 10, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'type', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: 'type.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - ], - emptyFields: ['sourcetype'], - visibleMetricFieldsCount: 1, - totalMetricFieldsCount: 1, - populatedFieldsCount: 7, - totalFieldsCount: 8, - fieldNameFiltersResultCount: 2, - fieldTypeFiltersResultCount: 3, - }, - }; - - const farequoteKQLSearchTestData: TestData = { - suiteTitle: 'KQL saved search', - sourceIndexOrSavedSearch: 'ft_farequote_kuery', - fieldNameFilters: ['@version'], - fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT], - sampleSizeValidations: [ - { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, - { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, - ], - expected: { - totalDocCountFormatted: '34,415', - metricFields: [ - { - fieldName: 'responsetime', - type: ML_JOB_FIELD_TYPES.NUMBER, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - statsMaxDecimalPlaces: 3, - topValuesCount: 10, - viewableInLens: true, - }, - ], - nonMetricFields: [ - { - fieldName: '@timestamp', - type: ML_JOB_FIELD_TYPES.DATE, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - exampleCount: 2, - viewableInLens: true, - }, - { - fieldName: '@version', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: '@version.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'airline', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 5, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'type', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: 'type.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - ], - emptyFields: ['sourcetype'], - visibleMetricFieldsCount: 1, - totalMetricFieldsCount: 1, - populatedFieldsCount: 7, - totalFieldsCount: 8, - fieldNameFiltersResultCount: 1, - fieldTypeFiltersResultCount: 3, - }, - }; - - const farequoteLuceneSearchTestData: TestData = { - suiteTitle: 'lucene saved search', - sourceIndexOrSavedSearch: 'ft_farequote_lucene', - fieldNameFilters: ['@version.keyword', 'type'], - fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER], - sampleSizeValidations: [ - { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, - { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, - ], - expected: { - totalDocCountFormatted: '34,416', - metricFields: [ - { - fieldName: 'responsetime', - type: ML_JOB_FIELD_TYPES.NUMBER, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - statsMaxDecimalPlaces: 3, - topValuesCount: 10, - viewableInLens: true, - }, - ], - nonMetricFields: [ - { - fieldName: '@timestamp', - type: ML_JOB_FIELD_TYPES.DATE, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '5000 (100%)', - exampleCount: 2, - viewableInLens: true, - }, - { - fieldName: '@version', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: '@version.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'airline', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 5, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - { - fieldName: 'type', - type: ML_JOB_FIELD_TYPES.TEXT, - existsInDocs: true, - aggregatable: false, - loading: false, - exampleCount: 1, - docCountFormatted: '', - viewableInLens: false, - }, - { - fieldName: 'type.keyword', - type: ML_JOB_FIELD_TYPES.KEYWORD, - existsInDocs: true, - aggregatable: true, - loading: false, - exampleCount: 1, - docCountFormatted: '5000 (100%)', - viewableInLens: true, - }, - ], - emptyFields: ['sourcetype'], - visibleMetricFieldsCount: 1, - totalMetricFieldsCount: 1, - populatedFieldsCount: 7, - totalFieldsCount: 8, - fieldNameFiltersResultCount: 2, - fieldTypeFiltersResultCount: 1, - }, - }; - - const sampleLogTestData: TestData = { - suiteTitle: 'geo point field', - sourceIndexOrSavedSearch: 'ft_module_sample_logs', - fieldNameFilters: ['geo.coordinates'], - fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], - rowsPerPage: 50, - expected: { - totalDocCountFormatted: '408', - metricFields: [], - // only testing the geo_point fields - nonMetricFields: [ - { - fieldName: 'geo.coordinates', - type: ML_JOB_FIELD_TYPES.GEO_POINT, - existsInDocs: true, - aggregatable: true, - loading: false, - docCountFormatted: '408 (100%)', - exampleCount: 10, - viewableInLens: false, - }, - ], - emptyFields: [], - visibleMetricFieldsCount: 4, - totalMetricFieldsCount: 5, - populatedFieldsCount: 35, - totalFieldsCount: 36, - fieldNameFiltersResultCount: 1, - fieldTypeFiltersResultCount: 1, - }, - sampleSizeValidations: [ - { size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } }, - { size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } }, - ], - }; - function runTests(testData: TestData) { it(`${testData.suiteTitle} loads the source data in the data visualizer`, async () => { await ml.testExecution.logTestStep( @@ -541,7 +185,7 @@ export default function ({ getService }: FtrProviderContext) { }); describe('with module_sample_logs ', function () { - // Run tests on full farequote index. + // Run tests on full ft_module_sample_logs index. it(`${sampleLogTestData.suiteTitle} loads the data visualizer selector page`, async () => { // Start navigation from the base of the ML app. await ml.navigation.navigateToMl(); diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts new file mode 100644 index 00000000000000..ba24684e130362 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover.ts @@ -0,0 +1,172 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { TestData, MetricFieldVisConfig } from './types'; + +const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics'; +import { + farequoteDataViewTestData, + farequoteKQLSearchTestData, + farequoteLuceneFiltersSearchTestData, + farequoteKQLFiltersSearchTestData, + farequoteLuceneSearchTestData, + sampleLogTestData, +} from './index_test_data'; +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']); + const ml = getService('ml'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const toasts = getService('toasts'); + + const selectIndexPattern = async (indexPattern: string) => { + await retry.tryForTime(2 * 1000, async () => { + await PageObjects.discover.selectIndexPattern(indexPattern); + const indexPatternTitle = await testSubjects.getVisibleText('indexPattern-switch-link'); + expect(indexPatternTitle).to.be(indexPattern); + }); + }; + + const clearAdvancedSetting = async (propertyName: string) => { + await retry.tryForTime(2 * 1000, async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/settings', { + shouldUseHashForSubUrl: false, + }); + if ((await PageObjects.settings.getAdvancedSettingCheckbox(propertyName)) === 'true') { + await PageObjects.settings.clearAdvancedSettings(propertyName); + } + }); + }; + + const setAdvancedSettingCheckbox = async (propertyName: string, checkedState: boolean) => { + await retry.tryForTime(2 * 1000, async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/settings', { + shouldUseHashForSubUrl: false, + }); + await testSubjects.click('settings'); + await toasts.dismissAllToasts(); + await PageObjects.settings.toggleAdvancedSettingCheckbox(propertyName, checkedState); + }); + }; + + function runTestsWhenDisabled(testData: TestData) { + it('should not show view mode toggle or Field stats table', async function () { + await PageObjects.common.navigateToApp('discover'); + if (testData.isSavedSearch) { + await retry.tryForTime(2 * 1000, async () => { + await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch); + }); + } else { + await selectIndexPattern(testData.sourceIndexOrSavedSearch); + } + + await PageObjects.timePicker.setAbsoluteRange( + 'Jan 1, 2016 @ 00:00:00.000', + 'Nov 1, 2020 @ 00:00:00.000' + ); + + await PageObjects.discover.assertViewModeToggleNotExists(); + await PageObjects.discover.assertFieldStatsTableNotExists(); + }); + } + + function runTests(testData: TestData) { + describe(`with ${testData.suiteTitle}`, function () { + it(`displays the 'Field statistics' table content correctly`, async function () { + await PageObjects.common.navigateToApp('discover'); + if (testData.isSavedSearch) { + await retry.tryForTime(2 * 1000, async () => { + await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch); + }); + } else { + await selectIndexPattern(testData.sourceIndexOrSavedSearch); + } + await PageObjects.timePicker.setAbsoluteRange( + 'Jan 1, 2016 @ 00:00:00.000', + 'Nov 1, 2020 @ 00:00:00.000' + ); + + await PageObjects.discover.assertHitCount(testData.expected.totalDocCountFormatted); + await PageObjects.discover.assertViewModeToggleExists(); + await PageObjects.discover.clickViewModeFieldStatsButton(); + await ml.testExecution.logTestStep( + 'displays details for metric fields and non-metric fields correctly' + ); + for (const fieldRow of testData.expected.metricFields as Array< + Required + >) { + await ml.dataVisualizerTable.assertNumberFieldContents( + fieldRow.fieldName, + fieldRow.docCountFormatted, + fieldRow.topValuesCount, + fieldRow.viewableInLens + ); + } + + for (const fieldRow of testData.expected.nonMetricFields!) { + await ml.dataVisualizerTable.assertNonMetricFieldContents( + fieldRow.type, + fieldRow.fieldName!, + fieldRow.docCountFormatted, + fieldRow.exampleCount, + fieldRow.viewableInLens, + false, + fieldRow.exampleContent + ); + } + }); + }); + } + + describe('field statistics in Discover', function () { + before(async function () { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/module_sample_logs'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createIndexPatternIfNeeded('ft_module_sample_logs', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded(); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded(); + await ml.testResources.createSavedSearchFarequoteFilterAndLuceneIfNeeded(); + await ml.testResources.createSavedSearchFarequoteFilterAndKueryIfNeeded(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async function () { + await clearAdvancedSetting(SHOW_FIELD_STATISTICS); + }); + + describe('when enabled', function () { + before(async function () { + await setAdvancedSettingCheckbox(SHOW_FIELD_STATISTICS, true); + }); + + after(async function () { + await clearAdvancedSetting(SHOW_FIELD_STATISTICS); + }); + + runTests(farequoteDataViewTestData); + runTests(farequoteKQLSearchTestData); + runTests(farequoteLuceneSearchTestData); + runTests(farequoteKQLFiltersSearchTestData); + runTests(farequoteLuceneFiltersSearchTestData); + runTests(sampleLogTestData); + }); + + describe('when disabled', function () { + before(async function () { + // Ensure that the setting is set to default state which is false + await setAdvancedSettingCheckbox(SHOW_FIELD_STATISTICS, false); + }); + + runTestsWhenDisabled(farequoteDataViewTestData); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/data_visualizer/index_test_data.ts b/x-pack/test/functional/apps/ml/data_visualizer/index_test_data.ts new file mode 100644 index 00000000000000..6dd782487fdf85 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/index_test_data.ts @@ -0,0 +1,533 @@ +/* + * 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 { TestData } from './types'; +import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types'; + +export const farequoteDataViewTestData: TestData = { + suiteTitle: 'farequote index pattern', + isSavedSearch: false, + sourceIndexOrSavedSearch: 'ft_farequote', + fieldNameFilters: ['airline', '@timestamp'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], + expected: { + totalDocCountFormatted: '86,274', + metricFields: [ + { + fieldName: 'responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + }, + ], + nonMetricFields: [ + { + fieldName: '@timestamp', + type: ML_JOB_FIELD_TYPES.DATE, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + exampleCount: 2, + viewableInLens: true, + }, + { + fieldName: '@version', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: '@version.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'airline', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 10, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'type', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: 'type.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + ], + emptyFields: ['sourcetype'], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + fieldNameFiltersResultCount: 2, + fieldTypeFiltersResultCount: 3, + }, +}; + +export const farequoteKQLSearchTestData: TestData = { + suiteTitle: 'KQL saved search', + isSavedSearch: true, + sourceIndexOrSavedSearch: 'ft_farequote_kuery', + fieldNameFilters: ['@version'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], + expected: { + totalDocCountFormatted: '34,415', + metricFields: [ + { + fieldName: 'responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + }, + ], + nonMetricFields: [ + { + fieldName: '@timestamp', + type: ML_JOB_FIELD_TYPES.DATE, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + exampleCount: 2, + viewableInLens: true, + }, + { + fieldName: '@version', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: '@version.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'airline', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 5, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'type', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: 'type.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + ], + emptyFields: ['sourcetype'], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + fieldNameFiltersResultCount: 1, + fieldTypeFiltersResultCount: 3, + }, +}; + +export const farequoteKQLFiltersSearchTestData: TestData = { + suiteTitle: 'KQL saved search and filters', + isSavedSearch: true, + sourceIndexOrSavedSearch: 'ft_farequote_filter_and_kuery', + fieldNameFilters: ['@version'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], + expected: { + totalDocCountFormatted: '5,674', + metricFields: [ + { + fieldName: 'responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + }, + ], + nonMetricFields: [ + { + fieldName: '@timestamp', + type: ML_JOB_FIELD_TYPES.DATE, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + exampleCount: 2, + viewableInLens: true, + }, + { + fieldName: '@version', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: '@version.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'airline', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + exampleContent: ['ASA'], + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'type', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: 'type.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + ], + emptyFields: ['sourcetype'], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + fieldNameFiltersResultCount: 1, + fieldTypeFiltersResultCount: 3, + }, +}; + +export const farequoteLuceneSearchTestData: TestData = { + suiteTitle: 'lucene saved search', + isSavedSearch: true, + sourceIndexOrSavedSearch: 'ft_farequote_lucene', + fieldNameFilters: ['@version.keyword', 'type'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], + expected: { + totalDocCountFormatted: '34,416', + metricFields: [ + { + fieldName: 'responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + }, + ], + nonMetricFields: [ + { + fieldName: '@timestamp', + type: ML_JOB_FIELD_TYPES.DATE, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + exampleCount: 2, + viewableInLens: true, + }, + { + fieldName: '@version', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: '@version.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'airline', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 5, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'type', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: 'type.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + ], + emptyFields: ['sourcetype'], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + fieldNameFiltersResultCount: 2, + fieldTypeFiltersResultCount: 1, + }, +}; + +export const farequoteLuceneFiltersSearchTestData: TestData = { + suiteTitle: 'lucene saved search and filter', + isSavedSearch: true, + sourceIndexOrSavedSearch: 'ft_farequote_filter_and_lucene', + fieldNameFilters: ['@version.keyword', 'type'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER], + sampleSizeValidations: [ + { size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } }, + ], + expected: { + totalDocCountFormatted: '5,673', + metricFields: [ + { + fieldName: 'responsetime', + type: ML_JOB_FIELD_TYPES.NUMBER, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + statsMaxDecimalPlaces: 3, + topValuesCount: 10, + viewableInLens: true, + }, + ], + nonMetricFields: [ + { + fieldName: '@timestamp', + type: ML_JOB_FIELD_TYPES.DATE, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '5000 (100%)', + exampleCount: 2, + viewableInLens: true, + }, + { + fieldName: '@version', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: '@version.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'airline', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + exampleContent: ['ASA'], + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + { + fieldName: 'type', + type: ML_JOB_FIELD_TYPES.TEXT, + existsInDocs: true, + aggregatable: false, + loading: false, + exampleCount: 1, + docCountFormatted: '', + viewableInLens: false, + }, + { + fieldName: 'type.keyword', + type: ML_JOB_FIELD_TYPES.KEYWORD, + existsInDocs: true, + aggregatable: true, + loading: false, + exampleCount: 1, + docCountFormatted: '5000 (100%)', + viewableInLens: true, + }, + ], + emptyFields: ['sourcetype'], + visibleMetricFieldsCount: 1, + totalMetricFieldsCount: 1, + populatedFieldsCount: 7, + totalFieldsCount: 8, + fieldNameFiltersResultCount: 2, + fieldTypeFiltersResultCount: 1, + }, +}; + +export const sampleLogTestData: TestData = { + suiteTitle: 'geo point field', + isSavedSearch: false, + sourceIndexOrSavedSearch: 'ft_module_sample_logs', + fieldNameFilters: ['geo.coordinates'], + fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT], + rowsPerPage: 50, + expected: { + totalDocCountFormatted: '408', + metricFields: [], + // only testing the geo_point fields + nonMetricFields: [ + { + fieldName: 'geo.coordinates', + type: ML_JOB_FIELD_TYPES.GEO_POINT, + existsInDocs: true, + aggregatable: true, + loading: false, + docCountFormatted: '408 (100%)', + exampleCount: 10, + viewableInLens: false, + }, + ], + emptyFields: [], + visibleMetricFieldsCount: 4, + totalMetricFieldsCount: 5, + populatedFieldsCount: 35, + totalFieldsCount: 36, + fieldNameFiltersResultCount: 1, + fieldTypeFiltersResultCount: 1, + }, + sampleSizeValidations: [ + { size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } }, + { size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } }, + ], +}; diff --git a/x-pack/test/functional/apps/ml/data_visualizer/types.ts b/x-pack/test/functional/apps/ml/data_visualizer/types.ts new file mode 100644 index 00000000000000..5c3f890dba5612 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_visualizer/types.ts @@ -0,0 +1,47 @@ +/* + * 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 { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types'; + +export interface MetricFieldVisConfig extends FieldVisConfig { + statsMaxDecimalPlaces: number; + docCountFormatted: string; + topValuesCount: number; + viewableInLens: boolean; +} + +export interface NonMetricFieldVisConfig extends FieldVisConfig { + docCountFormatted: string; + exampleCount: number; + exampleContent?: string[]; + viewableInLens: boolean; +} + +export interface TestData { + suiteTitle: string; + isSavedSearch?: boolean; + sourceIndexOrSavedSearch: string; + fieldNameFilters: string[]; + fieldTypeFilters: string[]; + rowsPerPage?: 10 | 25 | 50; + sampleSizeValidations: Array<{ + size: number; + expected: { field: string; docCountFormatted: string }; + }>; + expected: { + totalDocCountFormatted: string; + metricFields?: MetricFieldVisConfig[]; + nonMetricFields?: NonMetricFieldVisConfig[]; + emptyFields: string[]; + visibleMetricFieldsCount: number; + totalMetricFieldsCount: number; + populatedFieldsCount: number; + totalFieldsCount: number; + fieldNameFiltersResultCount: number; + fieldTypeFiltersResultCount: number; + }; +} diff --git a/x-pack/test/functional/services/ml/custom_urls.ts b/x-pack/test/functional/services/ml/custom_urls.ts index 5b2bf0773719c3..3d26236741a8aa 100644 --- a/x-pack/test/functional/services/ml/custom_urls.ts +++ b/x-pack/test/functional/services/ml/custom_urls.ts @@ -169,7 +169,10 @@ export function MachineLearningCustomUrlsProvider({ async assertDiscoverCustomUrlAction(expectedHitCountFormatted: string) { await PageObjects.discover.waitForDiscoverAppOnScreen(); - await retry.tryForTime(5000, async () => { + // During cloud tests, the small browser width might cause hit count to be invisible + // so temporarily collapsing the sidebar ensures the count shows + await PageObjects.discover.closeSidebar(); + await retry.tryForTime(10 * 1000, async () => { const hitCount = await PageObjects.discover.getHitCount(); expect(hitCount).to.eql( expectedHitCountFormatted, diff --git a/x-pack/test/functional/services/ml/data_visualizer_table.ts b/x-pack/test/functional/services/ml/data_visualizer_table.ts index 8094f0ad1f8d2c..860f2bd86bec73 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_table.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_table.ts @@ -361,7 +361,27 @@ export function MachineLearningDataVisualizerTableProvider( }); } - public async assertTopValuesContents(fieldName: string, expectedTopValuesCount: number) { + public async assertTopValuesContent(fieldName: string, expectedTopValues: string[]) { + const selector = this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent'); + const topValuesElement = await testSubjects.find(selector); + const topValuesBars = await topValuesElement.findAllByTestSubject( + 'dataVisualizerFieldDataTopValueBar' + ); + + const topValuesBarsValues = await Promise.all( + topValuesBars.map(async (bar) => { + const visibleText = await bar.getVisibleText(); + return visibleText ? visibleText.split('\n')[0] : undefined; + }) + ); + + expect(topValuesBarsValues).to.eql( + expectedTopValues, + `Expected top values for field '${fieldName}' to equal '${expectedTopValues}' (got '${topValuesBarsValues}')` + ); + } + + public async assertTopValuesCount(fieldName: string, expectedTopValuesCount: number) { const selector = this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent'); const topValuesElement = await testSubjects.find(selector); const topValuesBars = await topValuesElement.findAllByTestSubject( @@ -401,7 +421,7 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.existOrFail( this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValues') ); - await this.assertTopValuesContents(fieldName, topValuesCount); + await this.assertTopValuesCount(fieldName, topValuesCount); if (checkDistributionPreviewExist) { await this.assertDistributionPreviewExist(fieldName); @@ -433,7 +453,8 @@ export function MachineLearningDataVisualizerTableProvider( public async assertKeywordFieldContents( fieldName: string, docCountFormatted: string, - topValuesCount: number + topValuesCount: number, + exampleContent?: string[] ) { await this.assertRowExists(fieldName); await this.assertFieldDocCount(fieldName, docCountFormatted); @@ -442,7 +463,11 @@ export function MachineLearningDataVisualizerTableProvider( await testSubjects.existOrFail( this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent') ); - await this.assertTopValuesContents(fieldName, topValuesCount); + await this.assertTopValuesCount(fieldName, topValuesCount); + + if (exampleContent) { + await this.assertTopValuesContent(fieldName, exampleContent); + } await this.ensureDetailsClosed(fieldName); } @@ -508,13 +533,19 @@ export function MachineLearningDataVisualizerTableProvider( docCountFormatted: string, exampleCount: number, viewableInLens: boolean, - hasActionMenu?: boolean + hasActionMenu?: boolean, + exampleContent?: string[] ) { // Currently the data used in the data visualizer tests only contains these field types. if (fieldType === ML_JOB_FIELD_TYPES.DATE) { await this.assertDateFieldContents(fieldName, docCountFormatted); } else if (fieldType === ML_JOB_FIELD_TYPES.KEYWORD) { - await this.assertKeywordFieldContents(fieldName, docCountFormatted, exampleCount); + await this.assertKeywordFieldContents( + fieldName, + docCountFormatted, + exampleCount, + exampleContent + ); } else if (fieldType === ML_JOB_FIELD_TYPES.TEXT) { await this.assertTextFieldContents(fieldName, docCountFormatted, exampleCount); } else if (fieldType === ML_JOB_FIELD_TYPES.GEO_POINT) { diff --git a/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts b/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts index 57a44a0b7952da..4d38e6a144a787 100644 --- a/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts +++ b/x-pack/test/functional_basic/apps/ml/data_visualizer/index.ts @@ -19,6 +19,11 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile( require.resolve('../../../../functional/apps/ml/data_visualizer/index_data_visualizer') ); + loadTestFile( + require.resolve( + '../../../../functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover' + ) + ); loadTestFile(require.resolve('./index_data_visualizer_actions_panel')); }); }