diff --git a/packages/kbn-unified-data-table/src/components/data_table.test.tsx b/packages/kbn-unified-data-table/src/components/data_table.test.tsx index c59149132cdf4..97bcc0e2c6654 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.test.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.test.tsx @@ -10,6 +10,7 @@ import { ReactWrapper } from 'enzyme'; import { EuiButton, EuiCopy, + EuiDataGrid, EuiDataGridCellValueElementProps, EuiDataGridCustomBodyProps, } from '@elastic/eui'; @@ -52,7 +53,7 @@ function getProps(): UnifiedDataTableProps { onSetColumns: jest.fn(), onSort: jest.fn(), rows: esHitsMock.map((hit) => buildDataTableRecord(hit, dataViewMock)), - sampleSize: 30, + sampleSizeState: 30, searchDescription: '', searchTitle: '', setExpandedDoc: jest.fn(), @@ -301,6 +302,74 @@ describe('UnifiedDataTable', () => { }); }); + describe('display settings', () => { + it('should include additional display settings if onUpdateSampleSize is provided', async () => { + const component = await getComponent({ + ...getProps(), + sampleSizeState: 150, + onUpdateSampleSize: jest.fn(), + onUpdateRowHeight: jest.fn(), + }); + + expect(component.find(EuiDataGrid).prop('toolbarVisibility')).toMatchInlineSnapshot(` + Object { + "additionalControls": , + "showColumnSelector": false, + "showDisplaySelector": Object { + "additionalDisplaySettings": , + "allowDensity": false, + "allowResetButton": false, + "allowRowHeight": true, + }, + "showFullScreenSelector": true, + "showSortSelector": true, + } + `); + }); + + it('should not include additional display settings if onUpdateSampleSize is not provided', async () => { + const component = await getComponent({ + ...getProps(), + sampleSizeState: 200, + onUpdateRowHeight: jest.fn(), + }); + + expect(component.find(EuiDataGrid).prop('toolbarVisibility')).toMatchInlineSnapshot(` + Object { + "additionalControls": , + "showColumnSelector": false, + "showDisplaySelector": Object { + "allowDensity": false, + "allowRowHeight": true, + }, + "showFullScreenSelector": true, + "showSortSelector": true, + } + `); + }); + + it('should hide display settings if no handlers provided', async () => { + const component = await getComponent({ + ...getProps(), + onUpdateRowHeight: undefined, + onUpdateSampleSize: undefined, + }); + + expect(component.find(EuiDataGrid).prop('toolbarVisibility')).toMatchInlineSnapshot(` + Object { + "additionalControls": , + "showColumnSelector": false, + "showDisplaySelector": undefined, + "showFullScreenSelector": true, + "showSortSelector": true, + } + `); + }); + }); + describe('externalControlColumns', () => { it('should render external leading control columns', async () => { const component = await getComponent({ diff --git a/packages/kbn-unified-data-table/src/components/data_table.tsx b/packages/kbn-unified-data-table/src/components/data_table.tsx index a1540e88a5cd6..22a625f479e3b 100644 --- a/packages/kbn-unified-data-table/src/components/data_table.tsx +++ b/packages/kbn-unified-data-table/src/components/data_table.tsx @@ -27,6 +27,7 @@ import { EuiDataGridControlColumn, EuiDataGridCustomBodyProps, EuiDataGridCellValueElementProps, + EuiDataGridToolBarVisibilityDisplaySelectorOptions, EuiDataGridStyle, } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -63,6 +64,7 @@ import { toolbarVisibility as toolbarVisibilityDefaults, } from '../constants'; import { UnifiedDataTableFooter } from './data_table_footer'; +import { UnifiedDataTableAdditionalDisplaySettings } from './data_table_additional_display_settings'; export type SortOrder = [string, string]; @@ -137,10 +139,6 @@ export interface UnifiedDataTableProps { * Array of documents provided by Elasticsearch */ rows?: DataTableRecord[]; - /** - * The max size of the documents returned by Elasticsearch - */ - sampleSize: number; /** * Function to set the expanded document, which is displayed in a flyout */ @@ -205,6 +203,18 @@ export interface UnifiedDataTableProps { * Update rows per page state */ onUpdateRowsPerPage?: (rowsPerPage: number) => void; + /** + * Configuration option to limit sample size slider + */ + maxAllowedSampleSize?: number; + /** + * The max size of the documents returned by Elasticsearch + */ + sampleSizeState: number; + /** + * Update rows per page state + */ + onUpdateSampleSize?: (sampleSize: number) => void; /** * Callback to execute on edit runtime field */ @@ -328,7 +338,6 @@ export const UnifiedDataTable = ({ onSetColumns, onSort, rows, - sampleSize, searchDescription, searchTitle, settings, @@ -342,6 +351,9 @@ export const UnifiedDataTable = ({ className, rowHeightState, onUpdateRowHeight, + maxAllowedSampleSize, + sampleSizeState, + onUpdateSampleSize, isPlainRecord = false, rowsPerPageState, onUpdateRowsPerPage, @@ -715,16 +727,27 @@ export const UnifiedDataTable = ({ [usedSelectedDocs, isFilterActive, rows, externalAdditionalControls] ); - const showDisplaySelector = useMemo( - () => - !!onUpdateRowHeight - ? { - allowDensity: false, - allowRowHeight: true, - } - : undefined, - [onUpdateRowHeight] - ); + const showDisplaySelector = useMemo(() => { + const options: EuiDataGridToolBarVisibilityDisplaySelectorOptions = {}; + + if (onUpdateRowHeight) { + options.allowDensity = false; + options.allowRowHeight = true; + } + + if (onUpdateSampleSize) { + options.allowResetButton = false; + options.additionalDisplaySettings = ( + + ); + } + + return Object.keys(options).length ? options : undefined; + }, [maxAllowedSampleSize, sampleSizeState, onUpdateRowHeight, onUpdateSampleSize]); const inMemory = useMemo(() => { return isPlainRecord && columns.length @@ -837,7 +860,7 @@ export const UnifiedDataTable = ({ fn); + +describe('UnifiedDataTableAdditionalDisplaySettings', function () { + describe('sampleSize', function () { + it('should work correctly', async () => { + const onChangeSampleSizeMock = jest.fn(); + + const component = mountWithIntl( + + ); + const input = findTestSubject(component, 'unifiedDataTableSampleSizeInput').last(); + expect(input.prop('value')).toBe(10); + + await act(async () => { + input.simulate('change', { + target: { + value: 100, + }, + }); + }); + + expect(onChangeSampleSizeMock).toHaveBeenCalledWith(100); + + await new Promise((resolve) => setTimeout(resolve, 0)); + component.update(); + + expect( + findTestSubject(component, 'unifiedDataTableSampleSizeInput').last().prop('value') + ).toBe(100); + }); + + it('should not execute the callback for an invalid input', async () => { + const invalidValue = 600; + const onChangeSampleSizeMock = jest.fn(); + + const component = mountWithIntl( + + ); + const input = findTestSubject(component, 'unifiedDataTableSampleSizeInput').last(); + expect(input.prop('value')).toBe(50); + + await act(async () => { + input.simulate('change', { + target: { + value: invalidValue, + }, + }); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + component.update(); + + expect( + findTestSubject(component, 'unifiedDataTableSampleSizeInput').last().prop('value') + ).toBe(invalidValue); + + expect(onChangeSampleSizeMock).not.toHaveBeenCalled(); + }); + + it('should render value changes correctly', async () => { + const onChangeSampleSizeMock = jest.fn(); + + const component = mountWithIntl( + + ); + + expect( + findTestSubject(component, 'unifiedDataTableSampleSizeInput').last().prop('value') + ).toBe(200); + + component.setProps({ + sampleSize: 500, + onChangeSampleSize: onChangeSampleSizeMock, + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + component.update(); + + expect( + findTestSubject(component, 'unifiedDataTableSampleSizeInput').last().prop('value') + ).toBe(500); + + expect(onChangeSampleSizeMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/kbn-unified-data-table/src/components/data_table_additional_display_settings.tsx b/packages/kbn-unified-data-table/src/components/data_table_additional_display_settings.tsx new file mode 100644 index 0000000000000..2555c5f253929 --- /dev/null +++ b/packages/kbn-unified-data-table/src/components/data_table_additional_display_settings.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { EuiFormRow, EuiRange } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; + +export const DEFAULT_MAX_ALLOWED_SAMPLE_SIZE = 1000; +export const MIN_ALLOWED_SAMPLE_SIZE = 1; +export const RANGE_MIN_SAMPLE_SIZE = 10; // it's necessary to be able to use `step={10}` configuration for EuiRange +export const RANGE_STEP_SAMPLE_SIZE = 10; + +export interface UnifiedDataTableAdditionalDisplaySettingsProps { + maxAllowedSampleSize?: number; + sampleSize: number; + onChangeSampleSize: (sampleSize: number) => void; +} + +export const UnifiedDataTableAdditionalDisplaySettings: React.FC< + UnifiedDataTableAdditionalDisplaySettingsProps +> = ({ + maxAllowedSampleSize = DEFAULT_MAX_ALLOWED_SAMPLE_SIZE, + sampleSize, + onChangeSampleSize, +}) => { + const [activeSampleSize, setActiveSampleSize] = useState(sampleSize); + const minRangeSampleSize = Math.max( + Math.min(RANGE_MIN_SAMPLE_SIZE, sampleSize), + MIN_ALLOWED_SAMPLE_SIZE + ); // flexible: allows to go lower than RANGE_MIN_SAMPLE_SIZE but greater than MIN_ALLOWED_SAMPLE_SIZE + + const debouncedOnChangeSampleSize = useMemo( + () => debounce(onChangeSampleSize, 300, { leading: false, trailing: true }), + [onChangeSampleSize] + ); + + const onChangeActiveSampleSize = useCallback( + (event) => { + if (!event.target.value) { + setActiveSampleSize(''); + return; + } + + const newSampleSize = Number(event.target.value); + + if (newSampleSize >= MIN_ALLOWED_SAMPLE_SIZE) { + setActiveSampleSize(newSampleSize); + if (newSampleSize <= maxAllowedSampleSize) { + debouncedOnChangeSampleSize(newSampleSize); + } + } + }, + [maxAllowedSampleSize, setActiveSampleSize, debouncedOnChangeSampleSize] + ); + + const sampleSizeLabel = i18n.translate('unifiedDataTable.sampleSizeSettings.sampleSizeLabel', { + defaultMessage: 'Sample size', + }); + + useEffect(() => { + setActiveSampleSize(sampleSize); // reset local state + }, [sampleSize, setActiveSampleSize]); + + return ( + + + + ); +}; diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 62780b66727ef..73fef09887c69 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -133,7 +133,7 @@ describe('checking migration metadata changes on all registered SO types', () => "risk-engine-configuration": "b105d4a3c6adce40708d729d12e5ef3c8fbd9508", "rules-settings": "892a2918ebaeba809a612b8d97cec0b07c800b5f", "sample-data-telemetry": "37441b12f5b0159c2d6d5138a494c9f440e950b5", - "search": "8d5184dd5b986d57250b6ffd9ae48a1925e4c7a3", + "search": "2c1ab8a17e6972be2fa8d3880ba2305dfd9a5a6e", "search-session": "b2fcd840e12a45039ada50b1355faeafa39876d1", "search-telemetry": "b568601618744720b5662946d3103e3fb75fe8ee", "security-rule": "07abb4d7e707d91675ec0495c73816394c7b521f", diff --git a/src/plugins/discover/public/application/context/context_app_content.tsx b/src/plugins/discover/public/application/context/context_app_content.tsx index ff99c46816f25..81ca3e6f81b66 100644 --- a/src/plugins/discover/public/application/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/context/context_app_content.tsx @@ -197,7 +197,7 @@ export function ContextAppContent({ dataView={dataView} expandedDoc={expandedDoc} loadingState={isAnchorLoading ? DataLoadingState.loading : DataLoadingState.loaded} - sampleSize={0} + sampleSizeState={0} sort={sort as SortOrder[]} isSortEnabled={false} showTimeCol={showTimeCol} diff --git a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx index d1c772e7ec1bf..60367b83d02ed 100644 --- a/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/plugins/discover/public/application/main/components/layout/discover_documents.tsx @@ -34,7 +34,6 @@ import { HIDE_ANNOUNCEMENTS, MAX_DOC_FIELDS_DISPLAYED, ROW_HEIGHT_OPTION, - SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, SHOW_MULTIFIELDS, SORT_DEFAULT_ORDER_SETTING, @@ -56,6 +55,10 @@ import { DiscoverTourProvider, } from '../../../../components/discover_tour'; import { getRawRecordType } from '../../utils/get_raw_record_type'; +import { + getMaxAllowedSampleSize, + getAllowedSampleSize, +} from '../../../../utils/get_allowed_sample_size'; import { DiscoverGridFlyout } from '../../../../components/discover_grid_flyout'; import { useSavedSearchInitial } from '../../services/discover_state_provider'; import { useFetchMoreRecords } from './use_fetch_more_records'; @@ -103,8 +106,8 @@ function DiscoverDocumentsComponent({ const documents$ = stateContainer.dataState.data$.documents$; const savedSearch = useSavedSearchInitial(); const { dataViews, capabilities, uiSettings, uiActions } = services; - const [query, sort, rowHeight, rowsPerPage, grid, columns, index] = useAppStateSelector( - (state) => { + const [query, sort, rowHeight, rowsPerPage, grid, columns, index, sampleSizeState] = + useAppStateSelector((state) => { return [ state.query, state.sort, @@ -113,9 +116,9 @@ function DiscoverDocumentsComponent({ state.grid, state.columns, state.index, + state.sampleSize, ]; - } - ); + }); const setExpandedDoc = useCallback( (doc: DataTableRecord | undefined) => { stateContainer.internalState.transitions.setExpandedDoc(doc); @@ -128,7 +131,6 @@ function DiscoverDocumentsComponent({ const useNewFieldsApi = useMemo(() => !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), [uiSettings]); const hideAnnouncements = useMemo(() => uiSettings.get(HIDE_ANNOUNCEMENTS), [uiSettings]); const isLegacy = useMemo(() => uiSettings.get(DOC_TABLE_LEGACY), [uiSettings]); - const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING), [uiSettings]); const documentState = useDataState(documents$); const isDataLoading = @@ -183,6 +185,13 @@ function DiscoverDocumentsComponent({ [stateContainer] ); + const onUpdateSampleSize = useCallback( + (newSampleSize: number) => { + stateContainer.appState.update({ sampleSize: newSampleSize }); + }, + [stateContainer] + ); + const onSort = useCallback( (nextSort: string[][]) => { stateContainer.appState.update({ sort: nextSort }); @@ -315,7 +324,6 @@ function DiscoverDocumentsComponent({ } rows={rows} sort={(sort as SortOrder[]) || []} - sampleSize={sampleSize} searchDescription={savedSearch.description} searchTitle={savedSearch.title} setExpandedDoc={setExpandedDoc} @@ -332,6 +340,9 @@ function DiscoverDocumentsComponent({ isPlainRecord={isTextBasedQuery} rowsPerPageState={rowsPerPage ?? getDefaultRowsPerPage(services.uiSettings)} onUpdateRowsPerPage={onUpdateRowsPerPage} + maxAllowedSampleSize={getMaxAllowedSampleSize(services.uiSettings)} + sampleSizeState={getAllowedSampleSize(sampleSizeState, services.uiSettings)} + onUpdateSampleSize={!isTextBasedQuery ? onUpdateSampleSize : undefined} onFieldEdited={onFieldEdited} configRowHeight={uiSettings.get(ROW_HEIGHT_OPTION)} showMultiFields={uiSettings.get(SHOW_MULTIFIELDS)} diff --git a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx index 4d1b9ccbdc22d..abae8e83b41a1 100644 --- a/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx +++ b/src/plugins/discover/public/application/main/components/top_nav/on_save_search.tsx @@ -15,6 +15,7 @@ import { SavedSearch, SaveSavedSearchOptions } from '@kbn/saved-search-plugin/pu import { DOC_TABLE_LEGACY } from '@kbn/discover-utils'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverStateContainer } from '../../services/discover_state'; +import { getAllowedSampleSize } from '../../../../utils/get_allowed_sample_size'; async function saveDataSource({ savedSearch, @@ -110,6 +111,7 @@ export async function onSaveSearch({ const currentTitle = savedSearch.title; const currentTimeRestore = savedSearch.timeRestore; const currentRowsPerPage = savedSearch.rowsPerPage; + const currentSampleSize = savedSearch.sampleSize; const currentDescription = savedSearch.description; const currentTags = savedSearch.tags; savedSearch.title = newTitle; @@ -118,6 +120,15 @@ export async function onSaveSearch({ savedSearch.rowsPerPage = uiSettings.get(DOC_TABLE_LEGACY) ? currentRowsPerPage : state.appState.getState().rowsPerPage; + + // save the custom value or reset it if it's invalid + const appStateSampleSize = state.appState.getState().sampleSize; + const allowedSampleSize = getAllowedSampleSize(appStateSampleSize, uiSettings); + savedSearch.sampleSize = + appStateSampleSize && allowedSampleSize === appStateSampleSize + ? appStateSampleSize + : undefined; + if (savedObjectsTagging) { savedSearch.tags = newTags; } @@ -144,6 +155,7 @@ export async function onSaveSearch({ savedSearch.title = currentTitle; savedSearch.timeRestore = currentTimeRestore; savedSearch.rowsPerPage = currentRowsPerPage; + savedSearch.sampleSize = currentSampleSize; savedSearch.description = currentDescription; if (savedObjectsTagging) { savedSearch.tags = currentTags; diff --git a/src/plugins/discover/public/application/main/hooks/utils/build_state_subscribe.ts b/src/plugins/discover/public/application/main/hooks/utils/build_state_subscribe.ts index 40838edd35c35..27407822553bb 100644 --- a/src/plugins/discover/public/application/main/hooks/utils/build_state_subscribe.ts +++ b/src/plugins/discover/public/application/main/hooks/utils/build_state_subscribe.ts @@ -53,7 +53,7 @@ export const buildStateSubscribe = return; } addLog('[appstate] subscribe triggered', nextState); - const { hideChart, interval, breakdownField, sort, index } = prevState; + const { hideChart, interval, breakdownField, sampleSize, sort, index } = prevState; const isTextBasedQueryLang = isTextBasedQuery(nextQuery); if (isTextBasedQueryLang) { @@ -68,6 +68,7 @@ export const buildStateSubscribe = const chartDisplayChanged = Boolean(nextState.hideChart) !== Boolean(hideChart); const chartIntervalChanged = nextState.interval !== interval && !isTextBasedQueryLang; const breakdownFieldChanged = nextState.breakdownField !== breakdownField; + const sampleSizeChanged = nextState.sampleSize !== sampleSize; const docTableSortChanged = !isEqual(nextState.sort, sort) && !isTextBasedQueryLang; const dataViewChanged = !isEqual(nextState.index, index) && !isTextBasedQueryLang; let savedSearchDataView; @@ -101,6 +102,7 @@ export const buildStateSubscribe = chartDisplayChanged || chartIntervalChanged || breakdownFieldChanged || + sampleSizeChanged || docTableSortChanged || dataViewChanged || queryChanged diff --git a/src/plugins/discover/public/application/main/services/discover_app_state_container.ts b/src/plugins/discover/public/application/main/services/discover_app_state_container.ts index 046e8fd6393f1..124c83beda236 100644 --- a/src/plugins/discover/public/application/main/services/discover_app_state_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_app_state_container.ts @@ -134,6 +134,10 @@ export interface DiscoverAppState { * Number of rows in the grid per page */ rowsPerPage?: number; + /** + * Custom sample size + */ + sampleSize?: number; /** * Breakdown field of chart */ @@ -299,7 +303,7 @@ export function getInitialState( ? defaultAppState : { ...defaultAppState, - ...cleanupUrlState(stateStorageURL), + ...cleanupUrlState(stateStorageURL, services.uiSettings), }, services.uiSettings ); diff --git a/src/plugins/discover/public/application/main/services/load_saved_search.ts b/src/plugins/discover/public/application/main/services/load_saved_search.ts index 3631ca876cce6..8b8dcc2beb2f4 100644 --- a/src/plugins/discover/public/application/main/services/load_saved_search.ts +++ b/src/plugins/discover/public/application/main/services/load_saved_search.ts @@ -91,7 +91,7 @@ export const loadSavedSearch = async ( // Update app state container with the next state derived from the next saved search const nextAppState = getInitialState(undefined, nextSavedSearch, services); const mergedAppState = appState - ? { ...nextAppState, ...cleanupUrlState({ ...appState }) } + ? { ...nextAppState, ...cleanupUrlState({ ...appState }, services.uiSettings) } : nextAppState; appStateContainer.resetToState(mergedAppState); diff --git a/src/plugins/discover/public/application/main/utils/cleanup_url_state.test.ts b/src/plugins/discover/public/application/main/utils/cleanup_url_state.test.ts index ea1af49f48e89..2d49639e02884 100644 --- a/src/plugins/discover/public/application/main/utils/cleanup_url_state.test.ts +++ b/src/plugins/discover/public/application/main/utils/cleanup_url_state.test.ts @@ -8,11 +8,14 @@ import { AppStateUrl } from '../services/discover_app_state_container'; import { cleanupUrlState } from './cleanup_url_state'; +import { createDiscoverServicesMock } from '../../../__mocks__/services'; + +const services = createDiscoverServicesMock(); describe('cleanupUrlState', () => { test('cleaning up legacy sort', async () => { const state = { sort: ['batman', 'desc'] } as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(` + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(` Object { "sort": Array [ Array [ @@ -25,7 +28,7 @@ describe('cleanupUrlState', () => { }); test('not cleaning up broken legacy sort', async () => { const state = { sort: ['batman'] } as unknown as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(`Object {}`); + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); }); test('not cleaning up regular sort', async () => { const state = { @@ -34,7 +37,7 @@ describe('cleanupUrlState', () => { ['robin', 'asc'], ], } as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(` + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(` Object { "sort": Array [ Array [ @@ -53,14 +56,14 @@ describe('cleanupUrlState', () => { const state = { sort: [], } as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(`Object {}`); + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); }); test('should keep a valid rowsPerPage', async () => { const state = { rowsPerPage: 50, } as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(` + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(` Object { "rowsPerPage": 50, } @@ -71,13 +74,63 @@ describe('cleanupUrlState', () => { const state = { rowsPerPage: -50, } as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(`Object {}`); + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); }); test('should remove an invalid rowsPerPage', async () => { const state = { rowsPerPage: 'test', } as unknown as AppStateUrl; - expect(cleanupUrlState(state)).toMatchInlineSnapshot(`Object {}`); + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); + }); + + describe('sampleSize', function () { + test('should keep a valid sampleSize', async () => { + const state = { + sampleSize: 50, + } as AppStateUrl; + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(` + Object { + "sampleSize": 50, + } + `); + }); + + test('should remove for ES|QL', async () => { + const state = { + sampleSize: 50, + query: { + esql: 'from test', + }, + } as AppStateUrl; + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(` + Object { + "query": Object { + "esql": "from test", + }, + } + `); + }); + + test('should remove a negative sampleSize', async () => { + const state = { + sampleSize: -50, + } as AppStateUrl; + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); + }); + + test('should remove an invalid sampleSize', async () => { + const state = { + sampleSize: 'test', + } as unknown as AppStateUrl; + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); + }); + + test('should remove a too large sampleSize', async () => { + const state = { + sampleSize: 500000, + } as AppStateUrl; + expect(cleanupUrlState(state, services.uiSettings)).toMatchInlineSnapshot(`Object {}`); + }); }); }); diff --git a/src/plugins/discover/public/application/main/utils/cleanup_url_state.ts b/src/plugins/discover/public/application/main/utils/cleanup_url_state.ts index 3abeed97d4cdc..cdfb95d87f134 100644 --- a/src/plugins/discover/public/application/main/utils/cleanup_url_state.ts +++ b/src/plugins/discover/public/application/main/utils/cleanup_url_state.ts @@ -6,14 +6,19 @@ * Side Public License, v 1. */ import { isOfAggregateQueryType } from '@kbn/es-query'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; import { DiscoverAppState, AppStateUrl } from '../services/discover_app_state_container'; import { migrateLegacyQuery } from '../../../utils/migrate_legacy_query'; +import { getMaxAllowedSampleSize } from '../../../utils/get_allowed_sample_size'; /** * Takes care of the given url state, migrates legacy props and cleans up empty props * @param appStateFromUrl */ -export function cleanupUrlState(appStateFromUrl: AppStateUrl): DiscoverAppState { +export function cleanupUrlState( + appStateFromUrl: AppStateUrl, + uiSettings: IUiSettingsClient +): DiscoverAppState { if ( appStateFromUrl && appStateFromUrl.query && @@ -46,5 +51,18 @@ export function cleanupUrlState(appStateFromUrl: AppStateUrl): DiscoverAppState delete appStateFromUrl.rowsPerPage; } + if ( + appStateFromUrl?.sampleSize && + (isOfAggregateQueryType(appStateFromUrl.query) || // not supported yet for ES|QL + !( + typeof appStateFromUrl.sampleSize === 'number' && + appStateFromUrl.sampleSize > 0 && + appStateFromUrl.sampleSize <= getMaxAllowedSampleSize(uiSettings) + )) + ) { + // remove the param if it's invalid + delete appStateFromUrl.sampleSize; + } + return appStateFromUrl as DiscoverAppState; } diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts index cbc62a3cd6068..36847a0a08929 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.test.ts @@ -26,6 +26,7 @@ const getDeps = () => searchSessionId: '123', services: discoverServiceMock, savedSearch: savedSearchMock, + getAppState: () => ({ sampleSize: 100 }), } as unknown as FetchDeps); describe('test fetchDocuments', () => { diff --git a/src/plugins/discover/public/application/main/utils/fetch_documents.ts b/src/plugins/discover/public/application/main/utils/fetch_documents.ts index 99e87f13558a8..b1e18273479bf 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.ts @@ -9,10 +9,11 @@ import { i18n } from '@kbn/i18n'; import { filter, map } from 'rxjs/operators'; import { lastValueFrom } from 'rxjs'; import { isRunningResponse, ISearchSource } from '@kbn/data-plugin/public'; -import { SAMPLE_SIZE_SETTING, buildDataTableRecordList } from '@kbn/discover-utils'; +import { buildDataTableRecordList } from '@kbn/discover-utils'; import type { EsHitRecord } from '@kbn/discover-utils/types'; import { getSearchResponseInterceptedWarnings } from '@kbn/search-response-warnings'; import type { RecordsFetchResponse } from '../../types'; +import { getAllowedSampleSize } from '../../../utils/get_allowed_sample_size'; import { FetchDeps } from './fetch_all'; /** @@ -21,9 +22,10 @@ import { FetchDeps } from './fetch_all'; */ export const fetchDocuments = ( searchSource: ISearchSource, - { abortController, inspectorAdapters, searchSessionId, services }: FetchDeps + { abortController, inspectorAdapters, searchSessionId, services, getAppState }: FetchDeps ): Promise => { - searchSource.setField('size', services.uiSettings.get(SAMPLE_SIZE_SETTING)); + const sampleSize = getAppState().sampleSize; + searchSource.setField('size', getAllowedSampleSize(sampleSize, services.uiSettings)); searchSource.setField('trackTotalHits', false); searchSource.setField('highlightAll', true); searchSource.setField('version', true); diff --git a/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts b/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts index 19e9f6a64c88b..a659f543f9993 100644 --- a/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts +++ b/src/plugins/discover/public/application/main/utils/get_state_defaults.test.ts @@ -36,6 +36,7 @@ describe('getStateDefaults', () => { "query": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": undefined, "savedQuery": undefined, "sort": Array [ Array [ @@ -70,6 +71,7 @@ describe('getStateDefaults', () => { "query": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": undefined, "savedQuery": undefined, "sort": Array [], "viewMode": undefined, diff --git a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts index 78c8946825374..943d9b4c98cf0 100644 --- a/src/plugins/discover/public/application/main/utils/get_state_defaults.ts +++ b/src/plugins/discover/public/application/main/utils/get_state_defaults.ts @@ -70,6 +70,7 @@ export function getStateDefaults({ savedQuery: undefined, rowHeight: undefined, rowsPerPage: undefined, + sampleSize: undefined, grid: undefined, breakdownField: undefined, }; @@ -94,7 +95,9 @@ export function getStateDefaults({ if (savedSearch.rowsPerPage) { defaultState.rowsPerPage = savedSearch.rowsPerPage; } - + if (savedSearch.sampleSize) { + defaultState.sampleSize = savedSearch.sampleSize; + } if (savedSearch.breakdownField) { defaultState.breakdownField = savedSearch.breakdownField; } diff --git a/src/plugins/discover/public/components/doc_table/create_doc_table_embeddable.tsx b/src/plugins/discover/public/components/doc_table/create_doc_table_embeddable.tsx index e0f24c2839113..a0a55a17a9cba 100644 --- a/src/plugins/discover/public/components/doc_table/create_doc_table_embeddable.tsx +++ b/src/plugins/discover/public/components/doc_table/create_doc_table_embeddable.tsx @@ -17,6 +17,7 @@ export function DiscoverDocTableEmbeddable(renderProps: DocTableEmbeddableProps) columns={renderProps.columns} rows={renderProps.rows} rowsPerPageState={renderProps.rowsPerPageState} + sampleSizeState={renderProps.sampleSizeState} onUpdateRowsPerPage={renderProps.onUpdateRowsPerPage} totalHitCount={renderProps.totalHitCount} dataView={renderProps.dataView} diff --git a/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx b/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx index fbe8ed083ebc3..36e3629f089aa 100644 --- a/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx +++ b/src/plugins/discover/public/components/doc_table/doc_table_embeddable.tsx @@ -10,19 +10,19 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'; import './index.scss'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiText } from '@elastic/eui'; -import { SAMPLE_SIZE_SETTING, usePager } from '@kbn/discover-utils'; +import { usePager } from '@kbn/discover-utils'; import type { SearchResponseInterceptedWarning } from '@kbn/search-response-warnings'; import { ToolBarPagination, MAX_ROWS_PER_PAGE_OPTION, } from './components/pager/tool_bar_pagination'; import { DocTableProps, DocTableRenderProps, DocTableWrapper } from './doc_table_wrapper'; -import { useDiscoverServices } from '../../hooks/use_discover_services'; import { SavedSearchEmbeddableBase } from '../../embeddable/saved_search_embeddable_base'; export interface DocTableEmbeddableProps extends DocTableProps { totalHitCount?: number; rowsPerPageState?: number; + sampleSizeState: number; interceptedWarnings?: SearchResponseInterceptedWarning[]; onUpdateRowsPerPage?: (rowsPerPage?: number) => void; } @@ -30,7 +30,6 @@ export interface DocTableEmbeddableProps extends DocTableProps { const DocTableWrapperMemoized = memo(DocTableWrapper); export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => { - const services = useDiscoverServices(); const onUpdateRowsPerPage = props.onUpdateRowsPerPage; const tableWrapperRef = useRef(null); const { @@ -83,10 +82,6 @@ export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => { [hasNextPage, props.rows.length, props.totalHitCount] ); - const sampleSize = useMemo(() => { - return services.uiSettings.get(SAMPLE_SIZE_SETTING, 500); - }, [services]); - const renderDocTable = useCallback( (renderProps: DocTableRenderProps) => { return ( @@ -112,7 +107,7 @@ export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => { ) : undefined diff --git a/src/plugins/discover/public/components/doc_table/doc_table_infinite.tsx b/src/plugins/discover/public/components/doc_table/doc_table_infinite.tsx index cb285746963ac..92265f731bf13 100644 --- a/src/plugins/discover/public/components/doc_table/doc_table_infinite.tsx +++ b/src/plugins/discover/public/components/doc_table/doc_table_infinite.tsx @@ -6,16 +6,17 @@ * Side Public License, v 1. */ -import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react'; import './index.scss'; import { FormattedMessage } from '@kbn/i18n-react'; import { debounce } from 'lodash'; import { EuiButtonEmpty } from '@elastic/eui'; -import { SAMPLE_SIZE_SETTING } from '@kbn/discover-utils'; import { DocTableProps, DocTableRenderProps, DocTableWrapper } from './doc_table_wrapper'; import { SkipBottomButton } from '../../application/main/components/skip_bottom_button'; import { shouldLoadNextDocPatch } from './utils/should_load_next_doc_patch'; import { useDiscoverServices } from '../../hooks/use_discover_services'; +import { getAllowedSampleSize } from '../../utils/get_allowed_sample_size'; +import { useAppStateSelector } from '../../application/main/services/discover_app_state_container'; const FOOTER_PADDING = { padding: 0 }; @@ -38,8 +39,9 @@ const DocTableInfiniteContent = ({ onBackToTop, }: DocTableInfiniteContentProps) => { const { uiSettings } = useDiscoverServices(); - - const sampleSize = useMemo(() => uiSettings.get(SAMPLE_SIZE_SETTING, 500), [uiSettings]); + const sampleSize = useAppStateSelector((state) => + getAllowedSampleSize(state.sampleSize, uiSettings) + ); const onSkipBottomButton = useCallback(() => { onSetMaxLimit(); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts index 1473d07ba72b6..eaa7680137fe3 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.test.ts @@ -118,6 +118,7 @@ describe('saved search embeddable', () => { columns: ['message', 'extension'], rowHeight: 30, rowsPerPage: 50, + sampleSize: 250, }; const searchInput: SearchInput = byValue ? { ...baseInput, attributes: {} as SavedSearchByValueAttributes } @@ -194,6 +195,11 @@ describe('saved search embeddable', () => { await waitOneTick(); expect(searchProps.rowsPerPageState).toEqual(100); + expect(searchProps.sampleSizeState).toEqual(250); + searchProps.onUpdateSampleSize!(300); + await waitOneTick(); + expect(searchProps.sampleSizeState).toEqual(300); + searchProps.onFilter!({ name: 'customer_id', type: 'string', scripted: false }, [17], '+'); await waitOneTick(); expect(executeTriggerActions).toHaveBeenCalled(); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 34f6043936d92..e5896215e56de 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -53,7 +53,6 @@ import type { DataTableRecord, EsHitRecord } from '@kbn/discover-utils/types'; import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY, - SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, SHOW_FIELD_STATISTICS, SORT_DEFAULT_ORDER_SETTING, @@ -65,6 +64,7 @@ import { VIEW_MODE, getDefaultRowsPerPage } from '../../common/constants'; import type { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; import type { DiscoverServices } from '../build_services'; import { getSortForEmbeddable, SortPair } from '../utils/sorting'; +import { getMaxAllowedSampleSize, getAllowedSampleSize } from '../utils/get_allowed_sample_size'; import { SEARCH_EMBEDDABLE_TYPE, SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID } from './constants'; import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component'; import { handleSourceColumnState } from '../utils/state_helpers'; @@ -93,6 +93,7 @@ export type SearchProps = Partial & onMoveColumn?: (column: string, index: number) => void; onUpdateRowHeight?: (rowHeight?: number) => void; onUpdateRowsPerPage?: (rowsPerPage?: number) => void; + onUpdateSampleSize?: (sampleSize?: number) => void; }; export interface SearchEmbeddableConfig { @@ -126,6 +127,7 @@ export class SavedSearchEmbeddable private prevQuery?: Query; private prevSort?: SortOrder[]; private prevSearchSessionId?: string; + private prevSampleSizeInput?: number; private searchProps?: SearchProps; private initialized?: boolean; private node?: HTMLElement; @@ -256,6 +258,10 @@ export class SavedSearchEmbeddable return isTextBasedQuery(query); }; + private getFetchedSampleSize = (searchProps: SearchProps): number => { + return getAllowedSampleSize(searchProps.sampleSizeState, this.services.uiSettings); + }; + private fetch = async () => { const savedSearch = this.savedSearch; const searchProps = this.searchProps; @@ -276,9 +282,9 @@ export class SavedSearchEmbeddable savedSearch.searchSource, searchProps.dataView, searchProps.sort, + this.getFetchedSampleSize(searchProps), useNewFieldsApi, { - sampleSize: this.services.uiSettings.get(SAMPLE_SIZE_SETTING), sortDir: this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING), } ); @@ -472,7 +478,6 @@ export class SavedSearchEmbeddable }); this.updateInput({ sort: sortOrderArr }); }, - sampleSize: this.services.uiSettings.get(SAMPLE_SIZE_SETTING), onFilter: async (field, value, operator) => { let filters = generateFilters( this.services.filterManager, @@ -503,6 +508,10 @@ export class SavedSearchEmbeddable onUpdateRowsPerPage: (rowsPerPage) => { this.updateInput({ rowsPerPage }); }, + sampleSizeState: this.input.sampleSize || savedSearch.sampleSize, + onUpdateSampleSize: (sampleSize) => { + this.updateInput({ sampleSize }); + }, cellActionsTriggerId: SEARCH_EMBEDDABLE_CELL_ACTIONS_TRIGGER_ID, }; @@ -547,6 +556,7 @@ export class SavedSearchEmbeddable !isEqual(this.prevQuery, this.input.query) || !isEqual(this.prevTimeRange, this.getTimeRange()) || !isEqual(this.prevSort, this.input.sort) || + this.prevSampleSizeInput !== this.input.sampleSize || this.prevSearchSessionId !== this.input.searchSessionId ); } @@ -557,6 +567,7 @@ export class SavedSearchEmbeddable } return ( this.input.rowsPerPage !== searchProps.rowsPerPageState || + this.input.sampleSize !== searchProps.sampleSizeState || (this.input.columns && !isEqual(this.input.columns, searchProps.columns)) ); } @@ -589,6 +600,8 @@ export class SavedSearchEmbeddable this.input.rowsPerPage || savedSearch.rowsPerPage || getDefaultRowsPerPage(this.services.uiSettings); + searchProps.maxAllowedSampleSize = getMaxAllowedSampleSize(this.services.uiSettings); + searchProps.sampleSizeState = this.input.sampleSize || savedSearch.sampleSize; searchProps.filters = savedSearch.searchSource.getField('filter') as Filter[]; searchProps.savedSearchId = savedSearch.id; @@ -607,6 +620,7 @@ export class SavedSearchEmbeddable this.prevTimeRange = this.getTimeRange(); this.prevSearchSessionId = this.input.searchSessionId; this.prevSort = this.input.sort; + this.prevSampleSizeInput = this.input.sampleSize; this.searchProps = searchProps; await this.fetch(); @@ -692,7 +706,10 @@ export class SavedSearchEmbeddable > - + , diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx index 6c499a09d4152..43085e3c0902e 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx @@ -16,6 +16,7 @@ import { isTextBasedQuery } from '../application/main/utils/is_text_based_query' import { SearchProps } from './saved_search_embeddable'; interface SavedSearchEmbeddableComponentProps { + fetchedSampleSize: number; searchProps: SearchProps; useLegacyTable: boolean; query?: AggregateQuery | Query; @@ -25,6 +26,7 @@ const DiscoverDocTableEmbeddableMemoized = React.memo(DiscoverDocTableEmbeddable const DiscoverGridEmbeddableMemoized = React.memo(DiscoverGridEmbeddable); export function SavedSearchEmbeddableComponent({ + fetchedSampleSize, searchProps, useLegacyTable, query, @@ -34,6 +36,7 @@ export function SavedSearchEmbeddableComponent({ return ( ); @@ -41,6 +44,7 @@ export function SavedSearchEmbeddableComponent({ return ( { + sampleSizeState: number; // a required prop totalHitCount?: number; query?: AggregateQuery | Query; interceptedWarnings?: SearchResponseInterceptedWarning[]; diff --git a/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts b/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts index 6d440d89cf413..0b56ea8397728 100644 --- a/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts +++ b/src/plugins/discover/public/embeddable/utils/update_search_source.test.ts @@ -22,35 +22,65 @@ const dataViewMockWithTimeField = buildDataViewMock({ describe('updateSearchSource', () => { const defaults = { - sampleSize: 50, sortDir: 'asc', }; + const customSampleSize = 70; + it('updates a given search source', async () => { const searchSource = createSearchSourceMock({}); - updateSearchSource(searchSource, dataViewMock, [] as SortOrder[], false, defaults); + updateSearchSource( + searchSource, + dataViewMock, + [] as SortOrder[], + customSampleSize, + false, + defaults + ); expect(searchSource.getField('fields')).toBe(undefined); // does not explicitly request fieldsFromSource when not using fields API expect(searchSource.getField('fieldsFromSource')).toBe(undefined); + expect(searchSource.getField('size')).toEqual(customSampleSize); }); it('updates a given search source with the usage of the new fields api', async () => { const searchSource = createSearchSourceMock({}); - updateSearchSource(searchSource, dataViewMock, [] as SortOrder[], true, defaults); + updateSearchSource( + searchSource, + dataViewMock, + [] as SortOrder[], + customSampleSize, + true, + defaults + ); expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]); expect(searchSource.getField('fieldsFromSource')).toBe(undefined); + expect(searchSource.getField('size')).toEqual(customSampleSize); }); it('updates a given search source with sort field', async () => { const searchSource1 = createSearchSourceMock({}); - updateSearchSource(searchSource1, dataViewMock, [] as SortOrder[], true, defaults); + updateSearchSource( + searchSource1, + dataViewMock, + [] as SortOrder[], + customSampleSize, + true, + defaults + ); expect(searchSource1.getField('sort')).toEqual([{ _score: 'asc' }]); const searchSource2 = createSearchSourceMock({}); - updateSearchSource(searchSource2, dataViewMockWithTimeField, [] as SortOrder[], true, { - sampleSize: 50, - sortDir: 'desc', - }); + updateSearchSource( + searchSource2, + dataViewMockWithTimeField, + [] as SortOrder[], + customSampleSize, + true, + { + sortDir: 'desc', + } + ); expect(searchSource2.getField('sort')).toEqual([{ _doc: 'desc' }]); const searchSource3 = createSearchSourceMock({}); @@ -58,6 +88,7 @@ describe('updateSearchSource', () => { searchSource3, dataViewMockWithTimeField, [['bytes', 'desc']] as SortOrder[], + customSampleSize, true, defaults ); diff --git a/src/plugins/discover/public/embeddable/utils/update_search_source.ts b/src/plugins/discover/public/embeddable/utils/update_search_source.ts index 0215a26e649b0..ce2e72664e7d5 100644 --- a/src/plugins/discover/public/embeddable/utils/update_search_source.ts +++ b/src/plugins/discover/public/embeddable/utils/update_search_source.ts @@ -14,13 +14,13 @@ export const updateSearchSource = ( searchSource: ISearchSource, dataView: DataView | undefined, sort: (SortOrder[] & string[][]) | undefined, + sampleSize: number, useNewFieldsApi: boolean, defaults: { - sampleSize: number; sortDir: string; } ) => { - const { sampleSize, sortDir } = defaults; + const { sortDir } = defaults; searchSource.setField('size', sampleSize); searchSource.setField( 'sort', diff --git a/src/plugins/discover/public/utils/get_allowed_sample_size.test.ts b/src/plugins/discover/public/utils/get_allowed_sample_size.test.ts new file mode 100644 index 0000000000000..e7431dab6d478 --- /dev/null +++ b/src/plugins/discover/public/utils/get_allowed_sample_size.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { SAMPLE_SIZE_SETTING } from '@kbn/discover-utils'; +import { getAllowedSampleSize, getMaxAllowedSampleSize } from './get_allowed_sample_size'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +describe('allowed sample size', () => { + function getUiSettingsMock(sampleSize?: number): IUiSettingsClient { + return { + get: (key: string) => { + if (key === SAMPLE_SIZE_SETTING) { + return sampleSize; + } + }, + } as IUiSettingsClient; + } + + const uiSettings = getUiSettingsMock(500); + + describe('getAllowedSampleSize', function () { + test('should work correctly for a valid input', function () { + expect(getAllowedSampleSize(1, uiSettings)).toBe(1); + expect(getAllowedSampleSize(100, uiSettings)).toBe(100); + expect(getAllowedSampleSize(500, uiSettings)).toBe(500); + }); + + test('should work correctly for an invalid input', function () { + expect(getAllowedSampleSize(-10, uiSettings)).toBe(500); + expect(getAllowedSampleSize(undefined, uiSettings)).toBe(500); + expect(getAllowedSampleSize(50_000, uiSettings)).toBe(500); + }); + }); + + describe('getMaxAllowedSampleSize', function () { + test('should work correctly', function () { + expect(getMaxAllowedSampleSize(uiSettings)).toBe(500); + expect(getMaxAllowedSampleSize(getUiSettingsMock(1000))).toBe(1000); + expect(getMaxAllowedSampleSize(getUiSettingsMock(100))).toBe(100); + expect(getMaxAllowedSampleSize(getUiSettingsMock(20_000))).toBe(10_000); + expect(getMaxAllowedSampleSize(getUiSettingsMock(undefined))).toBe(500); + }); + }); +}); diff --git a/src/plugins/discover/public/utils/get_allowed_sample_size.ts b/src/plugins/discover/public/utils/get_allowed_sample_size.ts new file mode 100644 index 0000000000000..588a33545e2a7 --- /dev/null +++ b/src/plugins/discover/public/utils/get_allowed_sample_size.ts @@ -0,0 +1,30 @@ +/* + * 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 type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { SAMPLE_SIZE_SETTING } from '@kbn/discover-utils'; +import { + MIN_SAVED_SEARCH_SAMPLE_SIZE, + MAX_SAVED_SEARCH_SAMPLE_SIZE, +} from '@kbn/saved-search-plugin/common'; + +export const getMaxAllowedSampleSize = (uiSettings: IUiSettingsClient): number => { + return Math.min(uiSettings.get(SAMPLE_SIZE_SETTING) || 500, MAX_SAVED_SEARCH_SAMPLE_SIZE); +}; + +export const getAllowedSampleSize = ( + customSampleSize: number | undefined, + uiSettings: IUiSettingsClient +): number => { + if (!customSampleSize || customSampleSize < 0) { + return uiSettings.get(SAMPLE_SIZE_SETTING); + } + return Math.max( + Math.min(customSampleSize, getMaxAllowedSampleSize(uiSettings)), + MIN_SAVED_SEARCH_SAMPLE_SIZE + ); +}; diff --git a/src/plugins/saved_search/common/constants.ts b/src/plugins/saved_search/common/constants.ts index 57e3cfff51ebb..a980bd40e3e26 100644 --- a/src/plugins/saved_search/common/constants.ts +++ b/src/plugins/saved_search/common/constants.ts @@ -10,4 +10,7 @@ export const SavedSearchType = 'search'; export const LATEST_VERSION = 1; +export const MIN_SAVED_SEARCH_SAMPLE_SIZE = 1; +export const MAX_SAVED_SEARCH_SAMPLE_SIZE = 10000; + export type SavedSearchContentType = typeof SavedSearchType; diff --git a/src/plugins/saved_search/common/content_management/v1/cm_services.ts b/src/plugins/saved_search/common/content_management/v1/cm_services.ts index 781f111b18bfb..0cbbe69c4bfeb 100644 --- a/src/plugins/saved_search/common/content_management/v1/cm_services.ts +++ b/src/plugins/saved_search/common/content_management/v1/cm_services.ts @@ -15,6 +15,7 @@ import { updateOptionsSchema, createResultSchema, } from '@kbn/content-management-utils'; +import { MIN_SAVED_SEARCH_SAMPLE_SIZE, MAX_SAVED_SEARCH_SAMPLE_SIZE } from '../../constants'; const sortSchema = schema.arrayOf(schema.string(), { maxSize: 2 }); @@ -60,6 +61,12 @@ const savedSearchAttributesSchema = schema.object( }) ), rowsPerPage: schema.maybe(schema.number()), + sampleSize: schema.maybe( + schema.number({ + min: MIN_SAVED_SEARCH_SAMPLE_SIZE, + max: MAX_SAVED_SEARCH_SAMPLE_SIZE, + }) + ), breakdownField: schema.maybe(schema.string()), version: schema.maybe(schema.number()), }, diff --git a/src/plugins/saved_search/common/index.ts b/src/plugins/saved_search/common/index.ts index 4669ecd3bd4b9..0ac92232fb3b8 100644 --- a/src/plugins/saved_search/common/index.ts +++ b/src/plugins/saved_search/common/index.ts @@ -21,5 +21,10 @@ export enum VIEW_MODE { AGGREGATED_LEVEL = 'aggregated', } -export { SavedSearchType, LATEST_VERSION } from './constants'; +export { + SavedSearchType, + LATEST_VERSION, + MIN_SAVED_SEARCH_SAMPLE_SIZE, + MAX_SAVED_SEARCH_SAMPLE_SIZE, +} from './constants'; export { getKibanaContextFn } from './expressions/kibana_context'; diff --git a/src/plugins/saved_search/common/saved_searches_utils.ts b/src/plugins/saved_search/common/saved_searches_utils.ts index 324baca435232..d2a179e36817b 100644 --- a/src/plugins/saved_search/common/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/saved_searches_utils.ts @@ -32,5 +32,6 @@ export const fromSavedSearchAttributes = ( timeRange: attributes.timeRange, refreshInterval: attributes.refreshInterval, rowsPerPage: attributes.rowsPerPage, + sampleSize: attributes.sampleSize, breakdownField: attributes.breakdownField, }); diff --git a/src/plugins/saved_search/common/service/get_saved_searches.test.ts b/src/plugins/saved_search/common/service/get_saved_searches.test.ts index 05893f5c36e64..2b26b82eafece 100644 --- a/src/plugins/saved_search/common/service/get_saved_searches.test.ts +++ b/src/plugins/saved_search/common/service/get_saved_searches.test.ts @@ -58,6 +58,7 @@ describe('getSavedSearch', () => { description: 'description', grid: {}, hideChart: false, + sampleSize: 100, }, id: 'ccf1af80-2297-11ec-86e0-1155ffb9c7a7', type: 'search', @@ -103,6 +104,7 @@ describe('getSavedSearch', () => { "refreshInterval": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": 100, "searchSource": Object { "create": [MockFunction], "createChild": [MockFunction], @@ -208,6 +210,7 @@ describe('getSavedSearch', () => { "refreshInterval": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": undefined, "searchSource": Object { "create": [MockFunction], "createChild": [MockFunction], diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.test.ts b/src/plugins/saved_search/common/service/saved_searches_utils.test.ts index 67f368637d3f5..b118799858348 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.test.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.test.ts @@ -25,6 +25,9 @@ describe('saved_searches_utils', () => { hideChart: true, isTextBasedQuery: false, usesAdHocDataView: false, + rowsPerPage: 250, + sampleSize: 1000, + breakdownField: 'extension.keyword', }; expect( @@ -38,7 +41,7 @@ describe('saved_searches_utils', () => { ) ).toMatchInlineSnapshot(` Object { - "breakdownField": undefined, + "breakdownField": "extension.keyword", "columns": Array [ "a", "b", @@ -52,7 +55,8 @@ describe('saved_searches_utils', () => { "references": Array [], "refreshInterval": undefined, "rowHeight": undefined, - "rowsPerPage": undefined, + "rowsPerPage": 250, + "sampleSize": 1000, "searchSource": SearchSource { "dependencies": Object { "aggs": Object { @@ -122,6 +126,7 @@ describe('saved_searches_utils', () => { "refreshInterval": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": undefined, "sort": Array [ Array [ "a", diff --git a/src/plugins/saved_search/common/service/saved_searches_utils.ts b/src/plugins/saved_search/common/service/saved_searches_utils.ts index ef99a0b87ad5c..ab4720b7802f8 100644 --- a/src/plugins/saved_search/common/service/saved_searches_utils.ts +++ b/src/plugins/saved_search/common/service/saved_searches_utils.ts @@ -46,5 +46,6 @@ export const toSavedSearchAttributes = ( timeRange: savedSearch.timeRange ? pick(savedSearch.timeRange, ['from', 'to']) : undefined, refreshInterval: savedSearch.refreshInterval, rowsPerPage: savedSearch.rowsPerPage, + sampleSize: savedSearch.sampleSize, breakdownField: savedSearch.breakdownField, }); diff --git a/src/plugins/saved_search/common/types.ts b/src/plugins/saved_search/common/types.ts index 3da4276aeb1dd..c47548aebd8d4 100644 --- a/src/plugins/saved_search/common/types.ts +++ b/src/plugins/saved_search/common/types.ts @@ -43,6 +43,7 @@ export interface SavedSearchAttributes { refreshInterval?: RefreshInterval; rowsPerPage?: number; + sampleSize?: number; breakdownField?: string; } @@ -74,6 +75,7 @@ export interface SavedSearch { refreshInterval?: RefreshInterval; rowsPerPage?: number; + sampleSize?: number; breakdownField?: string; references?: SavedObjectReference[]; sharingSavedObjectProps?: { diff --git a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts index 9c7eb23c98e0a..a04f0af45eb29 100644 --- a/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/save_saved_searches.test.ts @@ -128,6 +128,7 @@ describe('saveSavedSearch', () => { refreshInterval: undefined, rowHeight: undefined, rowsPerPage: undefined, + sampleSize: undefined, sort: [], timeRange: undefined, timeRestore: false, @@ -162,6 +163,7 @@ describe('saveSavedSearch', () => { refreshInterval: undefined, rowHeight: undefined, rowsPerPage: undefined, + sampleSize: undefined, timeRange: undefined, sort: [], title: 'title', @@ -211,6 +213,7 @@ describe('saveSavedSearch', () => { refreshInterval: undefined, rowHeight: undefined, rowsPerPage: undefined, + sampleSize: undefined, sort: [], timeRange: undefined, timeRestore: false, diff --git a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts index cc6a6ec79ffea..35c35e669bff8 100644 --- a/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts +++ b/src/plugins/saved_search/public/services/saved_searches/saved_search_attribute_service.test.ts @@ -200,6 +200,7 @@ describe('getSavedSearchAttributeService', () => { "refreshInterval": undefined, "rowHeight": undefined, "rowsPerPage": undefined, + "sampleSize": undefined, "searchSource": Object { "create": [MockFunction], "createChild": [MockFunction], diff --git a/src/plugins/saved_search/public/services/saved_searches/types.ts b/src/plugins/saved_search/public/services/saved_searches/types.ts index 5e0f2637ae2aa..086d71848b6c6 100644 --- a/src/plugins/saved_search/public/services/saved_searches/types.ts +++ b/src/plugins/saved_search/public/services/saved_searches/types.ts @@ -34,6 +34,7 @@ interface SearchBaseInput extends EmbeddableInput { sort?: SortOrder[]; rowHeight?: number; rowsPerPage?: number; + sampleSize?: number; } export type SavedSearchByValueAttributes = Omit & { diff --git a/src/plugins/saved_search/server/content_management/saved_search_storage.ts b/src/plugins/saved_search/server/content_management/saved_search_storage.ts index 797430a159159..0615dbdc3049e 100644 --- a/src/plugins/saved_search/server/content_management/saved_search_storage.ts +++ b/src/plugins/saved_search/server/content_management/saved_search_storage.ts @@ -43,6 +43,7 @@ export class SavedSearchStorage extends SOContentStorage { 'refreshInterval', 'rowsPerPage', 'breakdownField', + 'sampleSize', ], logger, throwOnResultValidationError, diff --git a/src/plugins/saved_search/server/saved_objects/schema.ts b/src/plugins/saved_search/server/saved_objects/schema.ts new file mode 100644 index 0000000000000..19dfdf5e7a11c --- /dev/null +++ b/src/plugins/saved_search/server/saved_objects/schema.ts @@ -0,0 +1,95 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { + MIN_SAVED_SEARCH_SAMPLE_SIZE, + MAX_SAVED_SEARCH_SAMPLE_SIZE, + VIEW_MODE, +} from '../../common'; + +const SCHEMA_SEARCH_BASE = { + // General + title: schema.string(), + description: schema.string({ defaultValue: '' }), + + // Data grid + columns: schema.arrayOf(schema.string(), { defaultValue: [] }), + sort: schema.oneOf( + [ + schema.arrayOf(schema.arrayOf(schema.string(), { maxSize: 2 })), + schema.arrayOf(schema.string(), { maxSize: 2 }), + ], + { defaultValue: [] } + ), + grid: schema.object( + { + columns: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + width: schema.maybe(schema.number()), + }) + ) + ), + }, + { defaultValue: {} } + ), + rowHeight: schema.maybe(schema.number()), + rowsPerPage: schema.maybe(schema.number()), + + // Chart + hideChart: schema.boolean({ defaultValue: false }), + breakdownField: schema.maybe(schema.string()), + + // Search + kibanaSavedObjectMeta: schema.object({ + searchSourceJSON: schema.string(), + }), + isTextBasedQuery: schema.boolean({ defaultValue: false }), + usesAdHocDataView: schema.maybe(schema.boolean()), + + // Time + timeRestore: schema.maybe(schema.boolean()), + timeRange: schema.maybe( + schema.object({ + from: schema.string(), + to: schema.string(), + }) + ), + refreshInterval: schema.maybe( + schema.object({ + pause: schema.boolean(), + value: schema.number(), + }) + ), + + // Display + viewMode: schema.maybe( + schema.oneOf([ + schema.literal(VIEW_MODE.DOCUMENT_LEVEL), + schema.literal(VIEW_MODE.AGGREGATED_LEVEL), + ]) + ), + hideAggregatedPreview: schema.maybe(schema.boolean()), + + // Legacy + hits: schema.maybe(schema.number()), + version: schema.maybe(schema.number()), +}; + +export const SCHEMA_SEARCH_V8_8_0 = schema.object(SCHEMA_SEARCH_BASE); +export const SCHEMA_SEARCH_V8_12_0 = schema.object({ + ...SCHEMA_SEARCH_BASE, + sampleSize: schema.maybe( + schema.number({ + min: MIN_SAVED_SEARCH_SAMPLE_SIZE, + max: MAX_SAVED_SEARCH_SAMPLE_SIZE, + }) + ), +}); diff --git a/src/plugins/saved_search/server/saved_objects/search.ts b/src/plugins/saved_search/server/saved_objects/search.ts index 9b78f5ea4aecb..2d3844f098c6a 100644 --- a/src/plugins/saved_search/server/saved_objects/search.ts +++ b/src/plugins/saved_search/server/saved_objects/search.ts @@ -6,12 +6,11 @@ * Side Public License, v 1. */ -import { schema } from '@kbn/config-schema'; import { ANALYTICS_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; import { SavedObjectsType } from '@kbn/core/server'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; -import { VIEW_MODE } from '../../common'; import { getAllMigrations } from './search_migrations'; +import { SCHEMA_SEARCH_V8_8_0, SCHEMA_SEARCH_V8_12_0 } from './schema'; export function getSavedSearchObjectType( getSearchSourceMigrations: () => MigrateFunctionsObject @@ -44,75 +43,8 @@ export function getSavedSearchObjectType( }, }, schemas: { - '8.8.0': schema.object({ - // General - title: schema.string(), - description: schema.string({ defaultValue: '' }), - - // Data grid - columns: schema.arrayOf(schema.string(), { defaultValue: [] }), - sort: schema.oneOf( - [ - schema.arrayOf(schema.arrayOf(schema.string(), { maxSize: 2 })), - schema.arrayOf(schema.string(), { maxSize: 2 }), - ], - { defaultValue: [] } - ), - grid: schema.object( - { - columns: schema.maybe( - schema.recordOf( - schema.string(), - schema.object({ - width: schema.maybe(schema.number()), - }) - ) - ), - }, - { defaultValue: {} } - ), - rowHeight: schema.maybe(schema.number()), - rowsPerPage: schema.maybe(schema.number()), - - // Chart - hideChart: schema.boolean({ defaultValue: false }), - breakdownField: schema.maybe(schema.string()), - - // Search - kibanaSavedObjectMeta: schema.object({ - searchSourceJSON: schema.string(), - }), - isTextBasedQuery: schema.boolean({ defaultValue: false }), - usesAdHocDataView: schema.maybe(schema.boolean()), - - // Time - timeRestore: schema.maybe(schema.boolean()), - timeRange: schema.maybe( - schema.object({ - from: schema.string(), - to: schema.string(), - }) - ), - refreshInterval: schema.maybe( - schema.object({ - pause: schema.boolean(), - value: schema.number(), - }) - ), - - // Display - viewMode: schema.maybe( - schema.oneOf([ - schema.literal(VIEW_MODE.DOCUMENT_LEVEL), - schema.literal(VIEW_MODE.AGGREGATED_LEVEL), - ]) - ), - hideAggregatedPreview: schema.maybe(schema.boolean()), - - // Legacy - hits: schema.maybe(schema.number()), - version: schema.maybe(schema.number()), - }), + '8.8.0': SCHEMA_SEARCH_V8_8_0, + '8.12.0': SCHEMA_SEARCH_V8_12_0, }, migrations: () => getAllMigrations(getSearchSourceMigrations()), }; diff --git a/test/functional/apps/discover/group2/_data_grid_row_height.ts b/test/functional/apps/discover/group2/_data_grid_row_height.ts index 2c385b67aaa02..84574655cb406 100644 --- a/test/functional/apps/discover/group2/_data_grid_row_height.ts +++ b/test/functional/apps/discover/group2/_data_grid_row_height.ts @@ -14,6 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const dataGrid = getService('dataGrid'); + const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['settings', 'common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*' }; const security = getService('security'); @@ -47,7 +48,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await dataGrid.getCurrentRowHeightValue()).to.be('Auto fit'); }); - it('should allow to change row height and reset it', async () => { + it('should allow to change row height', async () => { await dataGrid.clickGridSettings(); expect(await dataGrid.getCurrentRowHeightValue()).to.be('Auto fit'); @@ -59,13 +60,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await dataGrid.getCurrentRowHeightValue()).to.be('Single'); - await dataGrid.resetRowHeightValue(); - - expect(await dataGrid.getCurrentRowHeightValue()).to.be('Auto fit'); + // we hide "Reset to default" action in Discover + await testSubjects.missingOrFail('resetDisplaySelector'); await dataGrid.changeRowHeightValue('Custom'); - await dataGrid.resetRowHeightValue(); + expect(await dataGrid.getCurrentRowHeightValue()).to.be('Custom'); + + await testSubjects.missingOrFail('resetDisplaySelector'); + + await dataGrid.changeRowHeightValue('Auto fit'); expect(await dataGrid.getCurrentRowHeightValue()).to.be('Auto fit'); }); diff --git a/test/functional/apps/discover/group2/_data_grid_sample_size.ts b/test/functional/apps/discover/group2/_data_grid_sample_size.ts new file mode 100644 index 0000000000000..891363f0868db --- /dev/null +++ b/test/functional/apps/discover/group2/_data_grid_sample_size.ts @@ -0,0 +1,195 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +const DEFAULT_ROWS_PER_PAGE = 100; +const DEFAULT_SAMPLE_SIZE = 500; +const CUSTOM_SAMPLE_SIZE = 250; +const CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH = 150; +const CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL = 10; +const FOOTER_SELECTOR = 'unifiedDataTableFooter'; +const SAVED_SEARCH_NAME = 'With sample size'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const browser = getService('browser'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const dataGrid = getService('dataGrid'); + const testSubjects = getService('testSubjects'); + const retry = getService('retry'); + const dashboardAddPanel = getService('dashboardAddPanel'); + const PageObjects = getPageObjects([ + 'settings', + 'common', + 'discover', + 'header', + 'timePicker', + 'dashboard', + ]); + const security = getService('security'); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:sampleSize': DEFAULT_SAMPLE_SIZE, + 'discover:rowHeightOption': 0, // single line + 'discover:sampleRowsPerPage': DEFAULT_ROWS_PER_PAGE, + hideAnnouncements: true, + }; + + describe('discover data grid sample size', function describeIndexTests() { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover'); + }); + + after(async () => { + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); + await kibanaServer.uiSettings.replace({}); + await kibanaServer.savedObjects.cleanStandardList(); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + async function goToLastPageAndCheckFooterMessage(sampleSize: number) { + const lastPageNumber = Math.ceil(sampleSize / DEFAULT_ROWS_PER_PAGE) - 1; + + // go to the last page + await testSubjects.click(`pagination-button-${lastPageNumber}`); + // footer is shown now + await retry.try(async function () { + await testSubjects.existOrFail(FOOTER_SELECTOR); + }); + expect( + (await testSubjects.getVisibleText(FOOTER_SELECTOR)).includes(String(sampleSize)) + ).to.be(true); + } + + it('should use the default sample size', async () => { + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(DEFAULT_SAMPLE_SIZE); + }); + + it('should allow to change sample size', async () => { + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + + await dataGrid.changeSampleSizeValue(CUSTOM_SAMPLE_SIZE); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(CUSTOM_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE); + }); + + it('should persist the selection after reloading the page', async () => { + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + + await dataGrid.changeSampleSizeValue(CUSTOM_SAMPLE_SIZE); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await browser.refresh(); + + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickGridSettings(); + + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(CUSTOM_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE); + }); + + it('should save a custom sample size with a search', async () => { + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + + await dataGrid.changeSampleSizeValue(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await PageObjects.discover.saveSearch(SAVED_SEARCH_NAME); + + await PageObjects.discover.waitUntilSearchingHasFinished(); + await dataGrid.clickGridSettings(); + + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + + // reset to the default value + await PageObjects.discover.clickNewSearchButton(); + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(DEFAULT_SAMPLE_SIZE); + + // load the saved search again + await PageObjects.discover.loadSavedSearch(SAVED_SEARCH_NAME); + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + + // load another saved search without a custom sample size + await PageObjects.discover.loadSavedSearch('A Saved Search'); + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(DEFAULT_SAMPLE_SIZE); + }); + + it('should use the default sample size on Dashboard', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.addSavedSearch('A Saved Search'); + + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(DEFAULT_SAMPLE_SIZE); + await goToLastPageAndCheckFooterMessage(DEFAULT_SAMPLE_SIZE); + }); + + it('should use custom sample size on Dashboard when specified', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await dashboardAddPanel.clickOpenAddPanel(); + await dashboardAddPanel.addSavedSearch(SAVED_SEARCH_NAME); + + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be(CUSTOM_SAMPLE_SIZE_FOR_SAVED_SEARCH); + + await dataGrid.changeSampleSizeValue(CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL); + + await PageObjects.header.waitUntilLoadingHasFinished(); + + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be( + CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL + ); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL); + + await PageObjects.dashboard.saveDashboard('test'); + + await browser.refresh(); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await dataGrid.clickGridSettings(); + expect(await dataGrid.getCurrentSampleSizeValue()).to.be( + CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL + ); + await goToLastPageAndCheckFooterMessage(CUSTOM_SAMPLE_SIZE_FOR_DASHBOARD_PANEL); + }); + }); +} diff --git a/test/functional/apps/discover/group2/index.ts b/test/functional/apps/discover/group2/index.ts index 8174e3ef93aba..6b35f6707bb78 100644 --- a/test/functional/apps/discover/group2/index.ts +++ b/test/functional/apps/discover/group2/index.ts @@ -28,6 +28,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./_data_grid_doc_table')); loadTestFile(require.resolve('./_data_grid_copy_to_clipboard')); loadTestFile(require.resolve('./_data_grid_row_height')); + loadTestFile(require.resolve('./_data_grid_sample_size')); loadTestFile(require.resolve('./_data_grid_pagination')); loadTestFile(require.resolve('./_data_grid_footer')); loadTestFile(require.resolve('./_data_grid_field_tokens')); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index 337fea7c3ff45..df5ba570cfc51 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -7,6 +7,7 @@ */ import { chunk } from 'lodash'; +import { Key } from 'selenium-webdriver'; import { FtrService } from '../ftr_provider_context'; import { WebElementWrapper } from './lib/web_element_wrapper'; @@ -366,6 +367,28 @@ export class DataGridService extends FtrService { await this.testSubjects.click('resetDisplaySelector'); } + private async findSampleSizeInput() { + return await this.find.byCssSelector( + 'input[type="number"][data-test-subj="unifiedDataTableSampleSizeInput"]' + ); + } + + public async getCurrentSampleSizeValue() { + const sampleSizeInput = await this.findSampleSizeInput(); + return Number(await sampleSizeInput.getAttribute('value')); + } + + public async changeSampleSizeValue(newValue: number) { + const sampleSizeInput = await this.findSampleSizeInput(); + await sampleSizeInput.focus(); + // replacing the input values with a new one + await sampleSizeInput.pressKeys([ + Key[process.platform === 'darwin' ? 'COMMAND' : 'CONTROL'], + 'a', + ]); + await sampleSizeInput.type(String(newValue)); + } + public async getDetailsRow(): Promise { const detailRows = await this.getDetailsRows(); return detailRows[0]; diff --git a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx index 12afa013aed18..e988a169219ea 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/cloud_security_data_table/cloud_security_data_table.tsx @@ -250,7 +250,7 @@ export const CloudSecurityDataTable = ({ onSetColumns={onSetColumns} onSort={onSort} rows={rows} - sampleSize={MAX_FINDINGS_TO_LOAD} + sampleSizeState={MAX_FINDINGS_TO_LOAD} setExpandedDoc={setExpandedDoc} renderDocumentView={renderDocumentView} sort={sort}