diff --git a/packages/kbn-discover-utils/src/__mocks__/es_hits.ts b/packages/kbn-discover-utils/src/__mocks__/es_hits.ts index a23bb1af0ceaf..84891fec5ab10 100644 --- a/packages/kbn-discover-utils/src/__mocks__/es_hits.ts +++ b/packages/kbn-discover-utils/src/__mocks__/es_hits.ts @@ -49,3 +49,8 @@ export const esHitsMock = [ }, }, ]; + +export const esHitsMockWithSort = esHitsMock.map((hit) => ({ + ...hit, + sort: [hit._source.date], // some `sort` param should be specified for "fetch more" to work +})); diff --git a/src/plugins/discover/common/constants.ts b/src/plugins/discover/common/constants.ts index 87a9378fa963c..3cb696766b65f 100644 --- a/src/plugins/discover/common/constants.ts +++ b/src/plugins/discover/common/constants.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +export const MAX_LOADED_GRID_ROWS = 10000; export const DEFAULT_ROWS_PER_PAGE = 100; export const ROWS_PER_PAGE_OPTIONS = [10, 25, 50, DEFAULT_ROWS_PER_PAGE, 250, 500]; export enum VIEW_MODE { diff --git a/src/plugins/discover/common/utils/sorting/get_es_query_sort.test.ts b/src/plugins/discover/common/utils/sorting/get_es_query_sort.test.ts new file mode 100644 index 0000000000000..e0b6923f8807a --- /dev/null +++ b/src/plugins/discover/common/utils/sorting/get_es_query_sort.test.ts @@ -0,0 +1,144 @@ +/* + * 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 { SortDirection } from '@kbn/data-plugin/common'; +import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { + getEsQuerySort, + getESQuerySortForTieBreaker, + getESQuerySortForTimeField, + getTieBreakerFieldName, +} from './get_es_query_sort'; +import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '@kbn/discover-utils'; +import { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; + +const dataView = createStubDataView({ + spec: { + id: 'logstash-*', + fields: { + test_field: { + name: 'test_field', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + test_field_not_sortable: { + name: 'test_field_not_sortable', + type: 'string', + esTypes: ['keyword'], + aggregatable: false, + searchable: false, + }, + }, + title: 'logstash-*', + timeFieldName: '@timestamp', + }, +}); + +describe('get_es_query_sort', function () { + test('getEsQuerySort should return sort params', function () { + expect( + getEsQuerySort({ + sortDir: SortDirection.desc, + timeFieldName: 'testTimeField', + isTimeNanosBased: false, + tieBreakerFieldName: 'testTieBreakerField', + }) + ).toStrictEqual([ + { testTimeField: { format: 'strict_date_optional_time', order: 'desc' } }, + { testTieBreakerField: 'desc' }, + ]); + + expect( + getEsQuerySort({ + sortDir: SortDirection.asc, + timeFieldName: 'testTimeField', + isTimeNanosBased: true, + tieBreakerFieldName: 'testTieBreakerField', + }) + ).toStrictEqual([ + { + testTimeField: { + format: 'strict_date_optional_time_nanos', + numeric_type: 'date_nanos', + order: 'asc', + }, + }, + { testTieBreakerField: 'asc' }, + ]); + }); + + test('getESQuerySortForTimeField should return time field as sort param', function () { + expect( + getESQuerySortForTimeField({ + sortDir: SortDirection.desc, + timeFieldName: 'testTimeField', + isTimeNanosBased: false, + }) + ).toStrictEqual({ + testTimeField: { + format: 'strict_date_optional_time', + order: 'desc', + }, + }); + + expect( + getESQuerySortForTimeField({ + sortDir: SortDirection.asc, + timeFieldName: 'testTimeField', + isTimeNanosBased: true, + }) + ).toStrictEqual({ + testTimeField: { + format: 'strict_date_optional_time_nanos', + numeric_type: 'date_nanos', + order: 'asc', + }, + }); + }); + + test('getESQuerySortForTieBreaker should return tie breaker as sort param', function () { + expect( + getESQuerySortForTieBreaker({ + sortDir: SortDirection.desc, + tieBreakerFieldName: 'testTieBreaker', + }) + ).toStrictEqual({ testTieBreaker: 'desc' }); + }); + + test('getTieBreakerFieldName should return a correct tie breaker', function () { + expect( + getTieBreakerFieldName(dataView, { + get: (key) => (key === CONTEXT_TIE_BREAKER_FIELDS_SETTING ? ['_doc'] : undefined), + } as IUiSettingsClient) + ).toBe('_doc'); + + expect( + getTieBreakerFieldName(dataView, { + get: (key) => + key === CONTEXT_TIE_BREAKER_FIELDS_SETTING + ? ['test_field_not_sortable', '_doc'] + : undefined, + } as IUiSettingsClient) + ).toBe('_doc'); + + expect( + getTieBreakerFieldName(dataView, { + get: (key) => + key === CONTEXT_TIE_BREAKER_FIELDS_SETTING ? ['test_field', '_doc'] : undefined, + } as IUiSettingsClient) + ).toBe('test_field'); + + expect( + getTieBreakerFieldName(dataView, { + get: (key) => undefined, + } as IUiSettingsClient) + ).toBeUndefined(); + }); +}); diff --git a/src/plugins/discover/common/utils/sorting/get_es_query_sort.ts b/src/plugins/discover/common/utils/sorting/get_es_query_sort.ts new file mode 100644 index 0000000000000..5fb4fa3d01a0b --- /dev/null +++ b/src/plugins/discover/common/utils/sorting/get_es_query_sort.ts @@ -0,0 +1,107 @@ +/* + * 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 { EsQuerySortValue, SortDirection } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { IUiSettingsClient } from '@kbn/core-ui-settings-browser'; +import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '@kbn/discover-utils'; + +/** + * Returns `EsQuerySort` which is used to sort records in the ES query + * https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html + * @param sortDir + * @param timeFieldName + * @param tieBreakerFieldName + * @param isTimeNanosBased + */ +export function getEsQuerySort({ + sortDir, + timeFieldName, + tieBreakerFieldName, + isTimeNanosBased, +}: { + sortDir: SortDirection; + timeFieldName: string; + tieBreakerFieldName: string; + isTimeNanosBased: boolean; +}): [EsQuerySortValue, EsQuerySortValue] { + return [ + getESQuerySortForTimeField({ sortDir, timeFieldName, isTimeNanosBased }), + getESQuerySortForTieBreaker({ sortDir, tieBreakerFieldName }), + ]; +} + +/** + * Prepares "sort" structure for a time field for next ES request + * @param sortDir + * @param timeFieldName + * @param isTimeNanosBased + */ +export function getESQuerySortForTimeField({ + sortDir, + timeFieldName, + isTimeNanosBased, +}: { + sortDir: SortDirection; + timeFieldName: string; + isTimeNanosBased: boolean; +}): EsQuerySortValue { + return { + [timeFieldName]: { + order: sortDir, + ...(isTimeNanosBased + ? { + format: 'strict_date_optional_time_nanos', + numeric_type: 'date_nanos', + } + : { format: 'strict_date_optional_time' }), + }, + }; +} + +/** + * Prepares "sort" structure for a tie breaker for next ES request + * @param sortDir + * @param tieBreakerFieldName + */ +export function getESQuerySortForTieBreaker({ + sortDir, + tieBreakerFieldName, +}: { + sortDir: SortDirection; + tieBreakerFieldName: string; +}): EsQuerySortValue { + return { [tieBreakerFieldName]: sortDir }; +} + +/** + * The default tie breaker for Discover + */ +export const DEFAULT_TIE_BREAKER_NAME = '_doc'; + +/** + * The list of field names that are allowed for sorting, but not included in + * data view fields. + */ +const META_FIELD_NAMES: string[] = ['_seq_no', '_doc', '_uid']; + +/** + * Returns a field from the intersection of the set of sortable fields in the + * given data view and a given set of candidate field names. + */ +export function getTieBreakerFieldName( + dataView: DataView, + uiSettings: Pick +) { + const sortableFields = (uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING) || []).filter( + (fieldName: string) => + META_FIELD_NAMES.includes(fieldName) || + (dataView.fields.getByName(fieldName) || { sortable: false }).sortable + ); + return sortableFields[0]; +} diff --git a/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.test.ts b/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.test.ts index dd54b5d2a70a2..415cf7ca9e76e 100644 --- a/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.test.ts +++ b/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.test.ts @@ -16,25 +16,80 @@ describe('getSortForSearchSource function', function () { test('should return an object to use for searchSource when columns are given', function () { const cols = [['bytes', 'desc']] as SortOrder[]; - expect(getSortForSearchSource(cols, stubDataView)).toEqual([{ bytes: 'desc' }]); - expect(getSortForSearchSource(cols, stubDataView, 'asc')).toEqual([{ bytes: 'desc' }]); + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataView, + defaultSortDir: 'desc', + includeTieBreaker: true, + }) + ).toEqual([{ bytes: 'desc' }, { _doc: 'desc' }]); - expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField)).toEqual([{ bytes: 'desc' }]); - expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField, 'asc')).toEqual([ - { bytes: 'desc' }, - ]); + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataView, + defaultSortDir: 'asc', + includeTieBreaker: true, + }) + ).toEqual([{ bytes: 'desc' }, { _doc: 'desc' }]); + + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataView, + defaultSortDir: 'asc', + }) + ).toEqual([{ bytes: 'desc' }]); + + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataViewWithoutTimeField, + defaultSortDir: 'desc', + includeTieBreaker: true, + }) + ).toEqual([{ bytes: 'desc' }]); + + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataViewWithoutTimeField, + defaultSortDir: 'asc', + }) + ).toEqual([{ bytes: 'desc' }]); }); test('should return an object to use for searchSource when no columns are given', function () { const cols = [] as SortOrder[]; - expect(getSortForSearchSource(cols, stubDataView)).toEqual([{ _doc: 'desc' }]); - expect(getSortForSearchSource(cols, stubDataView, 'asc')).toEqual([{ _doc: 'asc' }]); - - expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField)).toEqual([ - { _score: 'desc' }, - ]); - expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField, 'asc')).toEqual([ - { _score: 'asc' }, - ]); + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataView, + defaultSortDir: 'desc', + }) + ).toEqual([{ _doc: 'desc' }]); + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataView, + defaultSortDir: 'asc', + }) + ).toEqual([{ _doc: 'asc' }]); + + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataViewWithoutTimeField, + defaultSortDir: 'desc', + }) + ).toEqual([{ _score: 'desc' }]); + expect( + getSortForSearchSource({ + sort: cols, + dataView: stubDataViewWithoutTimeField, + defaultSortDir: 'asc', + }) + ).toEqual([{ _score: 'asc' }]); }); }); diff --git a/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.ts b/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.ts index bcf0ccf4d0e30..efd6c8b29cd30 100644 --- a/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.ts +++ b/src/plugins/discover/common/utils/sorting/get_sort_for_search_source.ts @@ -6,10 +6,15 @@ * Side Public License, v 1. */ -import type { DataView } from '@kbn/data-views-plugin/public'; -import type { EsQuerySortValue } from '@kbn/data-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/common'; +import type { EsQuerySortValue, SortDirection } from '@kbn/data-plugin/common'; import type { SortOrder } from '@kbn/saved-search-plugin/public'; import { getSort } from './get_sort'; +import { + getESQuerySortForTimeField, + getESQuerySortForTieBreaker, + DEFAULT_TIE_BREAKER_NAME, +} from './get_es_query_sort'; /** * Prepares sort for search source, that's sending the request to ES @@ -18,11 +23,19 @@ import { getSort } from './get_sort'; * the addon of the numeric_type guarantees the right sort order * when there are indices with date and indices with date_nanos field */ -export function getSortForSearchSource( - sort?: SortOrder[], - dataView?: DataView, - defaultDirection: string = 'desc' -): EsQuerySortValue[] { +export function getSortForSearchSource({ + sort, + dataView, + defaultSortDir, + includeTieBreaker = false, +}: { + sort: SortOrder[] | undefined; + dataView: DataView | undefined; + defaultSortDir: string; + includeTieBreaker?: boolean; +}): EsQuerySortValue[] { + const defaultDirection = defaultSortDir || 'desc'; + if (!sort || !dataView || (Array.isArray(sort) && sort.length === 0)) { if (dataView?.timeFieldName) { // sorting by index order @@ -31,16 +44,35 @@ export function getSortForSearchSource( return [{ _score: defaultDirection } as EsQuerySortValue]; } } + const { timeFieldName } = dataView; - return getSort(sort, dataView).map((sortPair: Record) => { - if (dataView.isTimeNanosBased() && timeFieldName && sortPair[timeFieldName]) { - return { - [timeFieldName]: { - order: sortPair[timeFieldName], - numeric_type: 'date_nanos', - }, - } as EsQuerySortValue; + const sortPairs = getSort(sort, dataView); + + const sortForSearchSource = sortPairs.map((sortPair: Record) => { + if (timeFieldName && sortPair[timeFieldName]) { + return getESQuerySortForTimeField({ + sortDir: sortPair[timeFieldName] as SortDirection, + timeFieldName, + isTimeNanosBased: dataView.isTimeNanosBased(), + }); } return sortPair as EsQuerySortValue; }); + + // This tie breaker is skipped for CSV reports as it uses PIT + if (includeTieBreaker && timeFieldName && sortPairs.length) { + const firstSortPair = sortPairs[0]; + const firstPairSortDir = Array.isArray(firstSortPair) + ? firstSortPair[1] + : Object.values(firstSortPair)[0]; + + sortForSearchSource.push( + getESQuerySortForTieBreaker({ + sortDir: firstPairSortDir, + tieBreakerFieldName: DEFAULT_TIE_BREAKER_NAME, + }) + ); + } + + return sortForSearchSource; } 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 fa9ed3b96c9ff..c72b7f366e5ce 100644 --- a/src/plugins/discover/public/application/context/context_app_content.tsx +++ b/src/plugins/discover/public/application/context/context_app_content.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, Fragment, useMemo, useCallback } from 'react'; +import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiHorizontalRule, EuiSpacer, EuiText } from '@elastic/eui'; import type { DataView } from '@kbn/data-views-plugin/public'; @@ -15,13 +15,13 @@ import type { SortOrder } from '@kbn/saved-search-plugin/public'; import { CellActionsProvider } from '@kbn/cell-actions'; import type { DataTableRecord } from '@kbn/discover-utils/types'; import { - SearchResponseWarnings, type SearchResponseInterceptedWarning, + SearchResponseWarnings, } from '@kbn/search-response-warnings'; import { CONTEXT_STEP_SETTING, DOC_HIDE_TIME_COLUMN_SETTING } from '@kbn/discover-utils'; import { LoadingStatus } from './services/context_query_state'; import { ActionBar } from './components/action_bar/action_bar'; -import { DiscoverGrid } from '../../components/discover_grid/discover_grid'; +import { DataLoadingState, DiscoverGrid } from '../../components/discover_grid/discover_grid'; import { DocViewFilterFn } from '../../services/doc_views/doc_views_types'; import { AppState } from './services/context_state'; import { SurrDocType } from './services/context'; @@ -169,7 +169,7 @@ export function ContextAppContent({ rows={rows} dataView={dataView} expandedDoc={expandedDoc} - isLoading={isAnchorLoading} + loadingState={isAnchorLoading ? DataLoadingState.loading : DataLoadingState.loaded} sampleSize={0} sort={sort as SortOrder[]} isSortEnabled={false} diff --git a/src/plugins/discover/public/application/context/hooks/use_context_app_fetch.tsx b/src/plugins/discover/public/application/context/hooks/use_context_app_fetch.tsx index 743e503227e78..eb69c5910e3f6 100644 --- a/src/plugins/discover/public/application/context/hooks/use_context_app_fetch.tsx +++ b/src/plugins/discover/public/application/context/hooks/use_context_app_fetch.tsx @@ -12,7 +12,6 @@ import { MarkdownSimple } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public'; import { SortDirection } from '@kbn/data-plugin/public'; import type { DataTableRecord } from '@kbn/discover-utils/types'; -import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '@kbn/discover-utils'; import { fetchAnchor } from '../services/anchor'; import { fetchSurroundingDocs, SurrDocType } from '../services/context'; import { @@ -22,8 +21,11 @@ import { LoadingStatus, } from '../services/context_query_state'; import { AppState } from '../services/context_state'; -import { getFirstSortableField } from '../utils/sorting'; import { useDiscoverServices } from '../../../hooks/use_discover_services'; +import { + getTieBreakerFieldName, + getEsQuerySort, +} from '../../../../common/utils/sorting/get_es_query_sort'; const createError = (statusKey: string, reason: FailureReason, error?: Error) => ({ [statusKey]: { value: LoadingStatus.FAILED, error, reason }, @@ -48,8 +50,8 @@ export function useContextAppFetch({ const searchSource = useMemo(() => { return data.search.searchSource.createEmpty(); }, [data.search.searchSource]); - const tieBreakerField = useMemo( - () => getFirstSortableField(dataView, config.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)), + const tieBreakerFieldName = useMemo( + () => getTieBreakerFieldName(dataView, config), [config, dataView] ); @@ -66,7 +68,7 @@ export function useContextAppFetch({ defaultMessage: 'Unable to load the anchor document', }); - if (!tieBreakerField) { + if (!tieBreakerFieldName) { setState(createError('anchorStatus', FailureReason.INVALID_TIEBREAKER)); toastNotifications.addDanger({ title: errorTitle, @@ -79,10 +81,12 @@ export function useContextAppFetch({ try { setState({ anchorStatus: { value: LoadingStatus.LOADING } }); - const sort = [ - { [dataView.timeFieldName!]: SortDirection.desc }, - { [tieBreakerField]: SortDirection.desc }, - ]; + const sort = getEsQuerySort({ + sortDir: SortDirection.desc, + timeFieldName: dataView.timeFieldName!, + tieBreakerFieldName, + isTimeNanosBased: dataView.isTimeNanosBased(), + }); const result = await fetchAnchor( anchorId, dataView, @@ -109,7 +113,7 @@ export function useContextAppFetch({ } }, [ services, - tieBreakerField, + tieBreakerFieldName, setState, toastNotifications, dataView, @@ -138,7 +142,7 @@ export function useContextAppFetch({ type, dataView, anchor, - tieBreakerField, + tieBreakerFieldName, SortDirection.desc, count, filters, @@ -168,7 +172,7 @@ export function useContextAppFetch({ filterManager, appState, fetchedState.anchor, - tieBreakerField, + tieBreakerFieldName, setState, dataView, toastNotifications, diff --git a/src/plugins/discover/public/application/context/services/context.ts b/src/plugins/discover/public/application/context/services/context.ts index 1386af851911e..df47356394821 100644 --- a/src/plugins/discover/public/application/context/services/context.ts +++ b/src/plugins/discover/public/application/context/services/context.ts @@ -18,7 +18,7 @@ import { convertIsoToMillis, extractNanos } from '../utils/date_conversion'; import { fetchHitsInInterval } from '../utils/fetch_hits_in_interval'; import { generateIntervals } from '../utils/generate_intervals'; import { getEsQuerySearchAfter } from '../utils/get_es_query_search_after'; -import { getEsQuerySort } from '../utils/get_es_query_sort'; +import { getEsQuerySort } from '../../../../common/utils/sorting/get_es_query_sort'; import type { DiscoverServices } from '../../../build_services'; export enum SurrDocType { @@ -88,16 +88,14 @@ export async function fetchSurroundingDocs( break; } - const searchAfter = getEsQuerySearchAfter( - type, - rows, - timeField, - anchor, - nanos, - useNewFieldsApi - ); + const searchAfter = getEsQuerySearchAfter(type, rows, anchor); - const sort = getEsQuerySort(timeField, tieBreakerField, sortDirToApply, nanos); + const sort = getEsQuerySort({ + timeFieldName: timeField, + tieBreakerFieldName: tieBreakerField, + sortDir: sortDirToApply, + isTimeNanosBased: dataView.isTimeNanosBased(), + }); const result = await fetchHitsInInterval( searchSource, diff --git a/src/plugins/discover/public/application/context/utils/get_es_query_search_after.ts b/src/plugins/discover/public/application/context/utils/get_es_query_search_after.ts index 51e2be09c054f..18792cb6a7ff8 100644 --- a/src/plugins/discover/public/application/context/utils/get_es_query_search_after.ts +++ b/src/plugins/discover/public/application/context/utils/get_es_query_search_after.ts @@ -17,35 +17,18 @@ import { SurrDocType } from '../services/context'; */ export function getEsQuerySearchAfter( type: SurrDocType, - documents: DataTableRecord[], - timeFieldName: string, - anchor: DataTableRecord, - nanoSeconds: string, - useNewFieldsApi?: boolean + rows: DataTableRecord[], + anchor: DataTableRecord ): EsQuerySearchAfter { - if (documents.length) { + if (rows.length) { // already surrounding docs -> first or last record is used - const afterTimeRecIdx = - type === SurrDocType.SUCCESSORS && documents.length ? documents.length - 1 : 0; - const afterTimeDoc = documents[afterTimeRecIdx]; - const afterTimeDocRaw = afterTimeDoc.raw; - let afterTimeValue = afterTimeDocRaw.sort?.[0] as string | number; - if (nanoSeconds) { - afterTimeValue = useNewFieldsApi - ? afterTimeDocRaw.fields?.[timeFieldName][0] - : afterTimeDocRaw._source?.[timeFieldName]; - } - return [afterTimeValue, afterTimeDoc.raw.sort?.[1] as string | number]; + const afterTimeRecIdx = type === SurrDocType.SUCCESSORS && rows.length ? rows.length - 1 : 0; + const afterTimeDocRaw = rows[afterTimeRecIdx].raw; + return [ + afterTimeDocRaw.sort?.[0] as string | number, + afterTimeDocRaw.sort?.[1] as string | number, + ]; } - // if data_nanos adapt timestamp value for sorting, since numeric value was rounded by browser // ES search_after also works when number is provided as string - const searchAfter = new Array(2) as EsQuerySearchAfter; - searchAfter[0] = anchor.raw.sort?.[0] as string | number; - if (nanoSeconds) { - searchAfter[0] = useNewFieldsApi - ? anchor.raw.fields?.[timeFieldName][0] - : anchor.raw._source?.[timeFieldName]; - } - searchAfter[1] = anchor.raw.sort?.[1] as string | number; - return searchAfter; + return [anchor.raw.sort?.[0] as string | number, anchor.raw.sort?.[1] as string | number]; } diff --git a/src/plugins/discover/public/application/context/utils/get_es_query_sort.ts b/src/plugins/discover/public/application/context/utils/get_es_query_sort.ts deleted file mode 100644 index 2bbea70e16160..0000000000000 --- a/src/plugins/discover/public/application/context/utils/get_es_query_sort.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import type { EsQuerySortValue, SortDirection } from '@kbn/data-plugin/public'; - -/** - * Returns `EsQuerySort` which is used to sort records in the ES query - * https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html - * @param timeField - * @param tieBreakerField - * @param sortDir - * @param nanos - */ -export function getEsQuerySort( - timeField: string, - tieBreakerField: string, - sortDir: SortDirection, - nanos?: string -): [EsQuerySortValue, EsQuerySortValue] { - return [ - { - [timeField]: { - order: sortDir, - format: nanos ? 'strict_date_optional_time_nanos' : 'strict_date_optional_time', - }, - }, - { [tieBreakerField]: sortDir }, - ]; -} 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 4b2b7aa8e7125..a378d25fb04de 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 @@ -24,15 +24,15 @@ import { SearchResponseWarnings } from '@kbn/search-response-warnings'; import { DOC_HIDE_TIME_COLUMN_SETTING, DOC_TABLE_LEGACY, + HIDE_ANNOUNCEMENTS, SAMPLE_SIZE_SETTING, SEARCH_FIELDS_FROM_SOURCE, - HIDE_ANNOUNCEMENTS, } from '@kbn/discover-utils'; import { useInternalStateSelector } from '../../services/discover_internal_state_container'; import { useAppStateSelector } from '../../services/discover_app_state_container'; import { useDiscoverServices } from '../../../../hooks/use_discover_services'; import { DocViewFilterFn } from '../../../../services/doc_views/doc_views_types'; -import { DiscoverGrid } from '../../../../components/discover_grid/discover_grid'; +import { DataLoadingState, DiscoverGrid } from '../../../../components/discover_grid/discover_grid'; import { FetchStatus } from '../../../types'; import { useColumns } from '../../../../hooks/use_data_grid_columns'; import { RecordRawType } from '../../services/discover_data_state_container'; @@ -46,6 +46,7 @@ import { getRawRecordType } from '../../utils/get_raw_record_type'; import { DiscoverGridFlyout } from '../../../../components/discover_grid/discover_grid_flyout'; import { DocViewer } from '../../../../services/doc_views/components/doc_viewer'; import { useSavedSearchInitial } from '../../services/discover_state_provider'; +import { useFetchMoreRecords } from './use_fetch_more_records'; const containerStyles = css` position: relative; @@ -56,7 +57,7 @@ const progressStyle = css` `; const DocTableInfiniteMemoized = React.memo(DocTableInfinite); -const DataGridMemoized = React.memo(DiscoverGrid); +const DiscoverGridMemoized = React.memo(DiscoverGrid); // export needs for testing export const onResize = ( @@ -134,6 +135,11 @@ function DiscoverDocumentsComponent({ isTextBasedQuery || !documentState.result || documentState.result.length === 0; const rows = useMemo(() => documentState.result || [], [documentState.result]); + const { isMoreDataLoading, totalHits, onFetchMoreRecords } = useFetchMoreRecords({ + isTextBasedQuery, + stateContainer, + }); + const { columns: currentColumns, onAddColumn, @@ -245,12 +251,18 @@ function DiscoverDocumentsComponent({ - diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx index ccdb6809f7c7c..e4a2a674c0f77 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.test.tsx @@ -362,7 +362,10 @@ describe('useDiscoverHistogram', () => { describe('refetching', () => { it('should call refetch when savedSearchFetch$ is triggered', async () => { const savedSearchFetch$ = new Subject<{ - reset: boolean; + options: { + reset: boolean; + fetchMore: boolean; + }; searchSessionId: string; }>(); const stateContainer = getStateContainer(); @@ -374,14 +377,20 @@ describe('useDiscoverHistogram', () => { }); expect(api.refetch).not.toHaveBeenCalled(); act(() => { - savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); + savedSearchFetch$.next({ + options: { reset: false, fetchMore: false }, + searchSessionId: '1234', + }); }); expect(api.refetch).toHaveBeenCalled(); }); it('should skip the next refetch when hideChart changes from true to false', async () => { const savedSearchFetch$ = new Subject<{ - reset: boolean; + options: { + reset: boolean; + fetchMore: boolean; + }; searchSessionId: string; }>(); const stateContainer = getStateContainer(); @@ -398,9 +407,44 @@ describe('useDiscoverHistogram', () => { hook.rerender({ ...initialProps, hideChart: false }); }); act(() => { - savedSearchFetch$.next({ reset: false, searchSessionId: '1234' }); + savedSearchFetch$.next({ + options: { reset: false, fetchMore: false }, + searchSessionId: '1234', + }); }); expect(api.refetch).not.toHaveBeenCalled(); }); + + it('should skip the next refetch when fetching more', async () => { + const savedSearchFetch$ = new Subject<{ + options: { + reset: boolean; + fetchMore: boolean; + }; + searchSessionId: string; + }>(); + const stateContainer = getStateContainer(); + stateContainer.dataState.fetch$ = savedSearchFetch$; + const { hook } = await renderUseDiscoverHistogram({ stateContainer }); + const api = createMockUnifiedHistogramApi(); + act(() => { + hook.result.current.ref(api); + }); + act(() => { + savedSearchFetch$.next({ + options: { reset: false, fetchMore: true }, + searchSessionId: '1234', + }); + }); + expect(api.refetch).not.toHaveBeenCalled(); + + act(() => { + savedSearchFetch$.next({ + options: { reset: false, fetchMore: false }, + searchSessionId: '1234', + }); + }); + expect(api.refetch).toHaveBeenCalled(); + }); }); }); diff --git a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts index 1aee76078c673..14144634ff28c 100644 --- a/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts +++ b/src/plugins/discover/public/application/main/components/layout/use_discover_histogram.ts @@ -279,7 +279,10 @@ export const useDiscoverHistogram = ({ textBasedFetchComplete$.pipe(map(() => 'discover')) ).pipe(debounceTime(50)); } else { - fetch$ = stateContainer.dataState.fetch$.pipe(map(() => 'discover')); + fetch$ = stateContainer.dataState.fetch$.pipe( + filter(({ options }) => !options.fetchMore), // don't update histogram for "Load more" in the grid + map(() => 'discover') + ); } const subscription = fetch$.subscribe((source) => { diff --git a/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.test.tsx b/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.test.tsx new file mode 100644 index 0000000000000..6851344538686 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.test.tsx @@ -0,0 +1,130 @@ +/* + * 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 { BehaviorSubject } from 'rxjs'; +import { renderHook } from '@testing-library/react-hooks'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { dataViewMock, esHitsMockWithSort } from '@kbn/discover-utils/src/__mocks__'; +import { useFetchMoreRecords } from './use_fetch_more_records'; +import { getDiscoverStateMock } from '../../../../__mocks__/discover_state.mock'; +import { DataDocuments$, DataTotalHits$ } from '../../services/discover_data_state_container'; +import { FetchStatus } from '../../../types'; + +describe('useFetchMoreRecords', () => { + const records = esHitsMockWithSort.map((hit) => buildDataTableRecord(hit, dataViewMock)); + + const getStateContainer = ({ + fetchStatus, + loadedRecordsCount, + totalRecordsCount, + }: { + fetchStatus: FetchStatus; + loadedRecordsCount: number; + totalRecordsCount: number; + }) => { + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.documents$ = new BehaviorSubject({ + fetchStatus, + result: records.slice(0, loadedRecordsCount), + }) as DataDocuments$; + stateContainer.dataState.data$.totalHits$ = new BehaviorSubject({ + fetchStatus, + result: totalRecordsCount, + }) as DataTotalHits$; + + return stateContainer; + }; + + it('should not be allowed if all records are already loaded', async () => { + const { + result: { current }, + } = renderHook(() => + useFetchMoreRecords({ + isTextBasedQuery: false, + stateContainer: getStateContainer({ + fetchStatus: FetchStatus.COMPLETE, + loadedRecordsCount: 3, + totalRecordsCount: 3, + }), + }) + ); + expect(current.onFetchMoreRecords).toBeUndefined(); + expect(current.isMoreDataLoading).toBe(false); + expect(current.totalHits).toBe(3); + }); + + it('should be allowed when there are more records to load', async () => { + const { + result: { current }, + } = renderHook(() => + useFetchMoreRecords({ + isTextBasedQuery: false, + stateContainer: getStateContainer({ + fetchStatus: FetchStatus.COMPLETE, + loadedRecordsCount: 3, + totalRecordsCount: 5, + }), + }) + ); + expect(current.onFetchMoreRecords).toBeDefined(); + expect(current.isMoreDataLoading).toBe(false); + expect(current.totalHits).toBe(5); + }); + + it('should not be allowed when there is no initial documents', async () => { + const { + result: { current }, + } = renderHook(() => + useFetchMoreRecords({ + isTextBasedQuery: false, + stateContainer: getStateContainer({ + fetchStatus: FetchStatus.COMPLETE, + loadedRecordsCount: 0, + totalRecordsCount: 5, + }), + }) + ); + expect(current.onFetchMoreRecords).toBeUndefined(); + expect(current.isMoreDataLoading).toBe(false); + expect(current.totalHits).toBe(5); + }); + + it('should return loading status correctly', async () => { + const { + result: { current }, + } = renderHook(() => + useFetchMoreRecords({ + isTextBasedQuery: false, + stateContainer: getStateContainer({ + fetchStatus: FetchStatus.LOADING_MORE, + loadedRecordsCount: 3, + totalRecordsCount: 5, + }), + }) + ); + expect(current.onFetchMoreRecords).toBeDefined(); + expect(current.isMoreDataLoading).toBe(true); + expect(current.totalHits).toBe(5); + }); + + it('should not be allowed for text-based queries', async () => { + const { + result: { current }, + } = renderHook(() => + useFetchMoreRecords({ + isTextBasedQuery: true, + stateContainer: getStateContainer({ + fetchStatus: FetchStatus.COMPLETE, + loadedRecordsCount: 3, + totalRecordsCount: 5, + }), + }) + ); + expect(current.onFetchMoreRecords).toBeUndefined(); + }); +}); diff --git a/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.ts b/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.ts new file mode 100644 index 0000000000000..e9b6c1c017853 --- /dev/null +++ b/src/plugins/discover/public/application/main/components/layout/use_fetch_more_records.ts @@ -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 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 { useMemo } from 'react'; +import { FetchStatus } from '../../../types'; +import { useDataState } from '../../hooks/use_data_state'; +import type { DiscoverStateContainer } from '../../services/discover_state'; + +/** + * Params for the hook + */ +export interface UseFetchMoreRecordsParams { + isTextBasedQuery: boolean; + stateContainer: DiscoverStateContainer; +} + +/** + * Return type for the hook + */ +export interface UseFetchMoreRecordsResult { + isMoreDataLoading: boolean; + totalHits: number; + onFetchMoreRecords: (() => void) | undefined; +} + +/** + * Checks if more records can be loaded and returns a handler for it + * @param isTextBasedQuery + * @param stateContainer + */ +export const useFetchMoreRecords = ({ + isTextBasedQuery, + stateContainer, +}: UseFetchMoreRecordsParams): UseFetchMoreRecordsResult => { + const documents$ = stateContainer.dataState.data$.documents$; + const totalHits$ = stateContainer.dataState.data$.totalHits$; + const documentState = useDataState(documents$); + const totalHitsState = useDataState(totalHits$); + + const rows = documentState.result || []; + const isMoreDataLoading = documentState.fetchStatus === FetchStatus.LOADING_MORE; + + const totalHits = totalHitsState.result || 0; + const canFetchMoreRecords = + !isTextBasedQuery && + rows.length > 0 && + totalHits > rows.length && + Boolean(rows[rows.length - 1].raw.sort?.length); + + const onFetchMoreRecords = useMemo( + () => + canFetchMoreRecords + ? () => { + stateContainer.dataState.fetchMore(); + } + : undefined, + [canFetchMoreRecords, stateContainer.dataState] + ); + + return { + isMoreDataLoading, + totalHits, + onFetchMoreRecords, + }; +}; diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts index fb0c5a7b2b3e0..7b96c2673f3eb 100644 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts +++ b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.test.ts @@ -11,13 +11,22 @@ import { sendErrorMsg, sendErrorTo, sendLoadingMsg, + sendLoadingMoreMsg, + sendLoadingMoreFinishedMsg, sendNoResultsFoundMsg, sendPartialMsg, } from './use_saved_search_messages'; import { FetchStatus } from '../../types'; import { BehaviorSubject } from 'rxjs'; -import { DataMainMsg, RecordRawType } from '../services/discover_data_state_container'; +import { + DataDocumentsMsg, + DataMainMsg, + RecordRawType, +} from '../services/discover_data_state_container'; import { filter } from 'rxjs/operators'; +import { dataViewMock, esHitsMockWithSort } from '@kbn/discover-utils/src/__mocks__'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { searchResponseWarningsMock } from '@kbn/search-response-warnings/src/__mocks__/search_response_warnings'; describe('test useSavedSearch message generators', () => { test('sendCompleteMsg', (done) => { @@ -69,6 +78,66 @@ describe('test useSavedSearch message generators', () => { recordRawType: RecordRawType.DOCUMENT, }); }); + test('sendLoadingMoreMsg', (done) => { + const documents$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + }); + documents$.subscribe((value) => { + if (value.fetchStatus !== FetchStatus.COMPLETE) { + expect(value.fetchStatus).toBe(FetchStatus.LOADING_MORE); + done(); + } + }); + sendLoadingMoreMsg(documents$); + }); + test('sendLoadingMoreFinishedMsg', (done) => { + const records = esHitsMockWithSort.map((hit) => buildDataTableRecord(hit, dataViewMock)); + const initialRecords = [records[0], records[1]]; + const moreRecords = [records[2], records[3]]; + + const documents$ = new BehaviorSubject({ + fetchStatus: FetchStatus.LOADING_MORE, + result: initialRecords, + }); + documents$.subscribe((value) => { + if (value.fetchStatus !== FetchStatus.LOADING_MORE) { + expect(value.fetchStatus).toBe(FetchStatus.COMPLETE); + expect(value.result).toStrictEqual([...initialRecords, ...moreRecords]); + expect(value.interceptedWarnings).toHaveLength(searchResponseWarningsMock.length); + done(); + } + }); + sendLoadingMoreFinishedMsg(documents$, { + moreRecords, + interceptedWarnings: searchResponseWarningsMock.map((warning) => ({ + originalWarning: warning, + })), + }); + }); + test('sendLoadingMoreFinishedMsg after an exception', (done) => { + const records = esHitsMockWithSort.map((hit) => buildDataTableRecord(hit, dataViewMock)); + const initialRecords = [records[0], records[1]]; + + const documents$ = new BehaviorSubject({ + fetchStatus: FetchStatus.LOADING_MORE, + result: initialRecords, + interceptedWarnings: searchResponseWarningsMock.map((warning) => ({ + originalWarning: warning, + })), + }); + documents$.subscribe((value) => { + if (value.fetchStatus !== FetchStatus.LOADING_MORE) { + expect(value.fetchStatus).toBe(FetchStatus.COMPLETE); + expect(value.result).toBe(initialRecords); + expect(value.interceptedWarnings).toBeUndefined(); + done(); + } + }); + sendLoadingMoreFinishedMsg(documents$, { + moreRecords: [], + interceptedWarnings: undefined, + }); + }); test('sendErrorMsg', (done) => { const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.PARTIAL }); main$.subscribe((value) => { diff --git a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts index 060b12053c870..8a49ea7098c7c 100644 --- a/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts +++ b/src/plugins/discover/public/application/main/hooks/use_saved_search_messages.ts @@ -7,6 +7,8 @@ */ import type { BehaviorSubject } from 'rxjs'; +import type { DataTableRecord } from '@kbn/discover-utils/src/types'; +import type { SearchResponseInterceptedWarning } from '@kbn/search-response-warnings'; import { FetchStatus } from '../../types'; import type { DataDocuments$, @@ -16,6 +18,7 @@ import type { SavedSearchData, } from '../services/discover_data_state_container'; import { RecordRawType } from '../services/discover_data_state_container'; + /** * Sends COMPLETE message to the main$ observable with the information * that no documents have been found, allowing Discover to show a no @@ -71,6 +74,44 @@ export function sendLoadingMsg( } } +/** + * Send LOADING_MORE message via main observable + */ +export function sendLoadingMoreMsg(documents$: DataDocuments$) { + if (documents$.getValue().fetchStatus !== FetchStatus.LOADING_MORE) { + documents$.next({ + ...documents$.getValue(), + fetchStatus: FetchStatus.LOADING_MORE, + }); + } +} + +/** + * Finishing LOADING_MORE message + */ +export function sendLoadingMoreFinishedMsg( + documents$: DataDocuments$, + { + moreRecords, + interceptedWarnings, + }: { + moreRecords: DataTableRecord[]; + interceptedWarnings: SearchResponseInterceptedWarning[] | undefined; + } +) { + const currentValue = documents$.getValue(); + if (currentValue.fetchStatus === FetchStatus.LOADING_MORE) { + documents$.next({ + ...currentValue, + fetchStatus: FetchStatus.COMPLETE, + result: moreRecords?.length + ? [...(currentValue.result || []), ...moreRecords] + : currentValue.result, + interceptedWarnings, + }); + } +} + /** * Send ERROR message */ diff --git a/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts b/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts index ccb3f4d39a877..d3f600dcfaff8 100644 --- a/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts +++ b/src/plugins/discover/public/application/main/services/discover_data_state_container.test.ts @@ -5,15 +5,28 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { waitFor } from '@testing-library/react'; +import { buildDataTableRecord } from '@kbn/discover-utils'; +import { dataViewMock, esHitsMockWithSort } from '@kbn/discover-utils/src/__mocks__'; import { discoverServiceMock } from '../../../__mocks__/services'; import { savedSearchMockWithSQL } from '../../../__mocks__/saved_search'; import { FetchStatus } from '../../types'; import { setUrlTracker } from '../../../kibana_services'; import { urlTrackerMock } from '../../../__mocks__/url_tracker.mock'; -import { RecordRawType } from './discover_data_state_container'; +import { DataDocuments$, RecordRawType } from './discover_data_state_container'; import { getDiscoverStateMock } from '../../../__mocks__/discover_state.mock'; +import { fetchDocuments } from '../utils/fetch_documents'; + +jest.mock('../utils/fetch_documents', () => ({ + fetchDocuments: jest.fn().mockResolvedValue({ records: [] }), +})); + +jest.mock('@kbn/ebt-tools', () => ({ + reportPerformanceMetricEvent: jest.fn(), +})); + +const mockFetchDocuments = fetchDocuments as unknown as jest.MockedFunction; setUrlTracker(urlTrackerMock); describe('test getDataStateContainer', () => { @@ -28,6 +41,11 @@ describe('test getDataStateContainer', () => { }); test('refetch$ triggers a search', async () => { const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + jest.spyOn(stateContainer.searchSessionManager, 'getNextSearchSessionId'); + jest.spyOn(stateContainer.searchSessionManager, 'getCurrentSearchSessionId'); + expect( + stateContainer.searchSessionManager.getNextSearchSessionId as jest.Mock + ).not.toHaveBeenCalled(); discoverServiceMock.data.query.timefilter.timefilter.getTime = jest.fn(() => { return { from: '2021-05-01T20:00:00Z', to: '2021-05-02T20:00:00Z' }; @@ -46,6 +64,15 @@ describe('test getDataStateContainer', () => { expect(dataState.data$.totalHits$.value.result).toBe(0); expect(dataState.data$.documents$.value.result).toEqual([]); + + // gets a new search session id + expect( + stateContainer.searchSessionManager.getNextSearchSessionId as jest.Mock + ).toHaveBeenCalled(); + expect( + stateContainer.searchSessionManager.getCurrentSearchSessionId as jest.Mock + ).not.toHaveBeenCalled(); + unsubscribe(); }); @@ -84,4 +111,51 @@ describe('test getDataStateContainer', () => { expect(stateContainer.dataState.data$.main$.getValue().recordRawType).toBe(RecordRawType.PLAIN); }); + + test('refetch$ accepts "fetch_more" signal', (done) => { + const records = esHitsMockWithSort.map((hit) => buildDataTableRecord(hit, dataViewMock)); + const initialRecords = [records[0], records[1]]; + const moreRecords = [records[2], records[3]]; + + mockFetchDocuments.mockResolvedValue({ records: moreRecords }); + + const stateContainer = getDiscoverStateMock({ isTimeBased: true }); + stateContainer.dataState.data$.documents$ = new BehaviorSubject({ + fetchStatus: FetchStatus.COMPLETE, + result: initialRecords, + }) as DataDocuments$; + + jest.spyOn(stateContainer.searchSessionManager, 'getCurrentSearchSessionId'); + expect( + stateContainer.searchSessionManager.getCurrentSearchSessionId as jest.Mock + ).not.toHaveBeenCalled(); + + const dataState = stateContainer.dataState; + + const unsubscribe = dataState.subscribe(); + + expect(dataState.data$.documents$.value.result).toEqual(initialRecords); + + let hasLoadingMoreStarted = false; + + stateContainer.dataState.data$.documents$.subscribe((value) => { + if (value.fetchStatus === FetchStatus.LOADING_MORE) { + hasLoadingMoreStarted = true; + return; + } + + if (hasLoadingMoreStarted && value.fetchStatus === FetchStatus.COMPLETE) { + expect(value.result).toEqual([...initialRecords, ...moreRecords]); + // it uses the same current search session id + expect( + stateContainer.searchSessionManager.getCurrentSearchSessionId as jest.Mock + ).toHaveBeenCalled(); + + unsubscribe(); + done(); + } + }); + + dataState.refetch$.next('fetch_more'); + }); }); diff --git a/src/plugins/discover/public/application/main/services/discover_data_state_container.ts b/src/plugins/discover/public/application/main/services/discover_data_state_container.ts index f077abc149939..f243d9884ca9e 100644 --- a/src/plugins/discover/public/application/main/services/discover_data_state_container.ts +++ b/src/plugins/discover/public/application/main/services/discover_data_state_container.ts @@ -25,7 +25,7 @@ import { DiscoverServices } from '../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; import { FetchStatus } from '../../types'; import { validateTimeRange } from '../utils/validate_time_range'; -import { fetchAll } from '../utils/fetch_all'; +import { fetchAll, fetchMoreDocuments } from '../utils/fetch_all'; import { sendResetMsg } from '../hooks/use_saved_search_messages'; import { getFetch$ } from '../utils/get_fetch_observable'; import { InternalState } from './discover_internal_state_container'; @@ -42,7 +42,10 @@ export type DataDocuments$ = BehaviorSubject; export type DataTotalHits$ = BehaviorSubject; export type AvailableFields$ = BehaviorSubject; export type DataFetch$ = Observable<{ - reset: boolean; + options: { + reset: boolean; + fetchMore: boolean; + }; searchSessionId: string; }>; @@ -59,7 +62,7 @@ export enum RecordRawType { PLAIN = 'plain', } -export type DataRefetchMsg = 'reset' | undefined; +export type DataRefetchMsg = 'reset' | 'fetch_more' | undefined; export interface DataMsg { fetchStatus: FetchStatus; @@ -95,6 +98,10 @@ export interface DiscoverDataStateContainer { * Implicitly starting fetching data from ES */ fetch: () => void; + /** + * Fetch more data from ES + */ + fetchMore: () => void; /** * Observable emitting when a next fetch is triggered */ @@ -197,22 +204,22 @@ export function getDataStateContainer({ filter(() => validateTimeRange(timefilter.getTime(), toastNotifications)), tap(() => inspectorAdapters.requests.reset()), map((val) => ({ - reset: val === 'reset', - searchSessionId: searchSessionManager.getNextSearchSessionId(), + options: { + reset: val === 'reset', + fetchMore: val === 'fetch_more', + }, + searchSessionId: + (val === 'fetch_more' && searchSessionManager.getCurrentSearchSessionId()) || + searchSessionManager.getNextSearchSessionId(), })), share() ); let abortController: AbortController; + let abortControllerFetchMore: AbortController; function subscribe() { - const subscription = fetch$.subscribe(async ({ reset, searchSessionId }) => { - abortController?.abort(); - abortController = new AbortController(); - const prevAutoRefreshDone = autoRefreshDone; - - const fetchAllStartTime = window.performance.now(); - await fetchAll(dataSubjects, reset, { - abortController, + const subscription = fetch$.subscribe(async ({ options, searchSessionId }) => { + const commonFetchDeps = { initialFetchStatus: getInitialFetchStatus(), inspectorAdapters, searchSessionId, @@ -221,6 +228,34 @@ export function getDataStateContainer({ getInternalState, savedSearch: getSavedSearch(), useNewFieldsApi: !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE), + }; + + abortController?.abort(); + abortControllerFetchMore?.abort(); + + if (options.fetchMore) { + abortControllerFetchMore = new AbortController(); + + const fetchMoreStartTime = window.performance.now(); + await fetchMoreDocuments(dataSubjects, { + abortController: abortControllerFetchMore, + ...commonFetchDeps, + }); + const fetchMoreDuration = window.performance.now() - fetchMoreStartTime; + reportPerformanceMetricEvent(services.analytics, { + eventName: 'discoverFetchMore', + duration: fetchMoreDuration, + }); + return; + } + + abortController = new AbortController(); + const prevAutoRefreshDone = autoRefreshDone; + + const fetchAllStartTime = window.performance.now(); + await fetchAll(dataSubjects, options.reset, { + abortController, + ...commonFetchDeps, }); const fetchAllDuration = window.performance.now() - fetchAllStartTime; reportPerformanceMetricEvent(services.analytics, { @@ -240,6 +275,7 @@ export function getDataStateContainer({ return () => { abortController?.abort(); + abortControllerFetchMore?.abort(); subscription.unsubscribe(); }; } @@ -263,6 +299,11 @@ export function getDataStateContainer({ return refetch$; }; + const fetchMore = () => { + refetch$.next('fetch_more'); + return refetch$; + }; + const reset = (savedSearch: SavedSearch) => { const recordType = getRawRecordType(savedSearch.searchSource.getField('query')); sendResetMsg(dataSubjects, getInitialFetchStatus(), recordType); @@ -270,6 +311,7 @@ export function getDataStateContainer({ return { fetch: fetchQuery, + fetchMore, fetch$, data$: dataSubjects, refetch$, diff --git a/src/plugins/discover/public/application/main/services/discover_search_session.ts b/src/plugins/discover/public/application/main/services/discover_search_session.ts index c6c64ed89c10a..c7379e706cf71 100644 --- a/src/plugins/discover/public/application/main/services/discover_search_session.ts +++ b/src/plugins/discover/public/application/main/services/discover_search_session.ts @@ -70,6 +70,13 @@ export class DiscoverSearchSessionManager { return searchSessionIdFromURL ?? this.deps.session.start(); } + /** + * Get current search session id + */ + getCurrentSearchSessionId() { + return this.deps.session.getSessionId(); + } + /** * Removes Discovers {@link SEARCH_SESSION_ID_QUERY_PARAM} from the URL * @param replace - methods to change the URL diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts index 1be1be3053ea6..7f19ec3a23695 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.test.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.test.ts @@ -12,7 +12,7 @@ import { SearchSource } from '@kbn/data-plugin/public'; import { RequestAdapter } from '@kbn/inspector-plugin/common'; import { savedSearchMock } from '../../../__mocks__/saved_search'; import { discoverServiceMock } from '../../../__mocks__/services'; -import { fetchAll } from './fetch_all'; +import { fetchAll, fetchMoreDocuments } from './fetch_all'; import { DataAvailableFieldsMsg, DataDocumentsMsg, @@ -24,7 +24,9 @@ import { import { fetchDocuments } from './fetch_documents'; import { fetchSql } from './fetch_sql'; import { buildDataTableRecord } from '@kbn/discover-utils'; -import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { dataViewMock, esHitsMockWithSort } from '@kbn/discover-utils/src/__mocks__'; +import { searchResponseWarningsMock } from '@kbn/search-response-warnings/src/__mocks__/search_response_warnings'; + jest.mock('./fetch_documents', () => ({ fetchDocuments: jest.fn().mockResolvedValue([]), })); @@ -288,4 +290,95 @@ describe('test fetchAll', () => { }, ]); }); + + describe('fetchMoreDocuments', () => { + const records = esHitsMockWithSort.map((hit) => buildDataTableRecord(hit, dataViewMock)); + const initialRecords = [records[0], records[1]]; + const moreRecords = [records[2], records[3]]; + + const interceptedWarnings = searchResponseWarningsMock.map((warning) => ({ + originalWarning: warning, + })); + + test('should add more records', async () => { + const collectDocuments = subjectCollector(subjects.documents$); + const collectMain = subjectCollector(subjects.main$); + mockFetchDocuments.mockResolvedValue({ records: moreRecords, interceptedWarnings }); + subjects.documents$.next({ + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }); + fetchMoreDocuments(subjects, deps); + await waitForNextTick(); + + expect(await collectDocuments()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }, + { + fetchStatus: FetchStatus.LOADING_MORE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }, + { + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: [...initialRecords, ...moreRecords], + interceptedWarnings, + }, + ]); + expect(await collectMain()).toEqual([ + { + fetchStatus: FetchStatus.UNINITIALIZED, + }, + ]); + }); + + test('should handle exceptions', async () => { + const collectDocuments = subjectCollector(subjects.documents$); + const collectMain = subjectCollector(subjects.main$); + mockFetchDocuments.mockRejectedValue({ msg: 'This query failed' }); + subjects.documents$.next({ + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }); + fetchMoreDocuments(subjects, deps); + await waitForNextTick(); + + expect(await collectDocuments()).toEqual([ + { fetchStatus: FetchStatus.UNINITIALIZED }, + { + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }, + { + fetchStatus: FetchStatus.LOADING_MORE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }, + { + fetchStatus: FetchStatus.COMPLETE, + recordRawType: RecordRawType.DOCUMENT, + result: initialRecords, + }, + ]); + expect(await collectMain()).toEqual([ + { + fetchStatus: FetchStatus.UNINITIALIZED, + }, + { + error: { + msg: 'This query failed', + }, + fetchStatus: 'error', + }, + ]); + }); + }); }); diff --git a/src/plugins/discover/public/application/main/utils/fetch_all.ts b/src/plugins/discover/public/application/main/utils/fetch_all.ts index cf17d8cc2bead..bf2bc0c1eb1b0 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_all.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_all.ts @@ -19,6 +19,8 @@ import { sendErrorMsg, sendErrorTo, sendLoadingMsg, + sendLoadingMoreMsg, + sendLoadingMoreFinishedMsg, sendResetMsg, } from '../hooks/use_saved_search_messages'; import { fetchDocuments } from './fetch_documents'; @@ -165,6 +167,60 @@ export function fetchAll( } } +export async function fetchMoreDocuments( + dataSubjects: SavedSearchData, + fetchDeps: FetchDeps +): Promise { + try { + const { getAppState, getInternalState, services, savedSearch } = fetchDeps; + const searchSource = savedSearch.searchSource.createChild(); + + const dataView = searchSource.getField('index')!; + const query = getAppState().query; + const recordRawType = getRawRecordType(query); + + if (recordRawType === RecordRawType.PLAIN) { + // not supported yet + return; + } + + const lastDocuments = dataSubjects.documents$.getValue().result || []; + const lastDocumentSort = lastDocuments[lastDocuments.length - 1]?.raw?.sort; + + if (!lastDocumentSort) { + return; + } + + searchSource.setField('searchAfter', lastDocumentSort); + + // Mark as loading + sendLoadingMoreMsg(dataSubjects.documents$); + + // Update the searchSource + updateVolatileSearchSource(searchSource, { + dataView, + services, + sort: getAppState().sort as SortOrder[], + customFilters: getInternalState().customFilters, + }); + + // Fetch more documents + const { records, interceptedWarnings } = await fetchDocuments(searchSource, fetchDeps); + + // Update the state and finish the loading state + sendLoadingMoreFinishedMsg(dataSubjects.documents$, { + moreRecords: records, + interceptedWarnings, + }); + } catch (error) { + sendLoadingMoreFinishedMsg(dataSubjects.documents$, { + moreRecords: [], + interceptedWarnings: undefined, + }); + sendErrorTo(dataSubjects.main$)(error); + } +} + const fetchStatusByType = (subject: BehaviorSubject, type: string) => subject.pipe(map(({ fetchStatus }) => ({ type, fetchStatus }))); 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 e8ae34aacd917..f850b283b3491 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 @@ -16,6 +16,7 @@ import { FetchDeps } from './fetch_all'; import type { EsHitRecord } from '@kbn/discover-utils/types'; import { buildDataTableRecord } from '@kbn/discover-utils'; import { dataViewMock } from '@kbn/discover-utils/src/__mocks__'; +import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; const getDeps = () => ({ @@ -48,4 +49,47 @@ describe('test fetchDocuments', () => { new Error('Oh noes!') ); }); + + test('passes a correct session id', async () => { + const deps = getDeps(); + const hits = [ + { _id: '1', foo: 'bar' }, + { _id: '2', foo: 'baz' }, + ] as unknown as EsHitRecord[]; + const documents = hits.map((hit) => buildDataTableRecord(hit, dataViewMock)); + + // regular search source + + const searchSourceRegular = createSearchSourceMock({ index: dataViewMock }); + searchSourceRegular.fetch$ = () => + of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse>); + + jest.spyOn(searchSourceRegular, 'fetch$'); + + expect(fetchDocuments(searchSourceRegular, deps)).resolves.toEqual({ + records: documents, + }); + + expect(searchSourceRegular.fetch$ as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: deps.searchSessionId }) + ); + + // search source with `search_after` for "Load more" requests + + const searchSourceForLoadMore = createSearchSourceMock({ index: dataViewMock }); + searchSourceForLoadMore.setField('searchAfter', ['100']); + + searchSourceForLoadMore.fetch$ = () => + of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse>); + + jest.spyOn(searchSourceForLoadMore, 'fetch$'); + + expect(fetchDocuments(searchSourceForLoadMore, deps)).resolves.toEqual({ + records: documents, + }); + + expect(searchSourceForLoadMore.fetch$ as jest.Mock).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: undefined }) + ); + }); }); 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 bce5f266d6def..767163259304f 100644 --- a/src/plugins/discover/public/application/main/utils/fetch_documents.ts +++ b/src/plugins/discover/public/application/main/utils/fetch_documents.ts @@ -36,20 +36,25 @@ export const fetchDocuments = ( searchSource.setOverwriteDataViewType(undefined); } const dataView = searchSource.getField('index')!; + const isFetchingMore = Boolean(searchSource.getField('searchAfter')); const executionContext = { - description: 'fetch documents', + description: isFetchingMore ? 'fetch more documents' : 'fetch documents', }; const fetch$ = searchSource .fetch$({ abortSignal: abortController.signal, - sessionId: searchSessionId, + sessionId: isFetchingMore ? undefined : searchSessionId, inspector: { adapter: inspectorAdapters.requests, - title: i18n.translate('discover.inspectorRequestDataTitleDocuments', { - defaultMessage: 'Documents', - }), + title: isFetchingMore // TODO: show it as a separate request in Inspect flyout + ? i18n.translate('discover.inspectorRequestDataTitleMoreDocuments', { + defaultMessage: 'More documents', + }) + : i18n.translate('discover.inspectorRequestDataTitleDocuments', { + defaultMessage: 'Documents', + }), description: i18n.translate('discover.inspectorRequestDescriptionDocument', { defaultMessage: 'This request queries Elasticsearch to fetch the documents.', }), diff --git a/src/plugins/discover/public/application/main/utils/update_search_source.ts b/src/plugins/discover/public/application/main/utils/update_search_source.ts index 87a7f6fabaf0b..64c5e1586bdc4 100644 --- a/src/plugins/discover/public/application/main/utils/update_search_source.ts +++ b/src/plugins/discover/public/application/main/utils/update_search_source.ts @@ -32,13 +32,17 @@ export function updateVolatileSearchSource( } ) { const { uiSettings, data } = services; - const usedSort = getSortForSearchSource( + const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); + + const usedSort = getSortForSearchSource({ sort, dataView, - uiSettings.get(SORT_DEFAULT_ORDER_SETTING) - ); - const useNewFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); - searchSource.setField('trackTotalHits', true).setField('sort', usedSort); + defaultSortDir: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + includeTieBreaker: true, + }); + searchSource.setField('sort', usedSort); + + searchSource.setField('trackTotalHits', true); let filters = [...customFilters]; diff --git a/src/plugins/discover/public/application/types.ts b/src/plugins/discover/public/application/types.ts index 588ca00f89825..57677236cbf7a 100644 --- a/src/plugins/discover/public/application/types.ts +++ b/src/plugins/discover/public/application/types.ts @@ -9,6 +9,7 @@ export enum FetchStatus { UNINITIALIZED = 'uninitialized', LOADING = 'loading', + LOADING_MORE = 'loading_more', PARTIAL = 'partial', COMPLETE = 'complete', ERROR = 'error', diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.scss b/src/plugins/discover/public/components/discover_grid/discover_grid.scss index c21651cec7dd9..0e870d366b609 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.scss +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.scss @@ -65,14 +65,6 @@ min-height: 0; } -.dscDiscoverGrid__footer { - flex-shrink: 0; - background-color: $euiColorLightShade; - padding: $euiSize / 2 $euiSize; - margin-top: $euiSize / 4; - text-align: center; -} - .dscTable__flyoutHeader { white-space: nowrap; } diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx index 78b42f7ef4133..153126b4d471c 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.test.tsx @@ -12,7 +12,7 @@ import { act } from 'react-dom/test-utils'; import { findTestSubject } from '@elastic/eui/lib/test'; import { buildDataViewMock, deepMockedFields, esHitsMock } from '@kbn/discover-utils/src/__mocks__'; import { mountWithIntl } from '@kbn/test-jest-helpers'; -import { DiscoverGrid, DiscoverGridProps } from './discover_grid'; +import { DiscoverGrid, DiscoverGridProps, DataLoadingState } from './discover_grid'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { discoverServiceMock } from '../../__mocks__/services'; import { buildDataTableRecord, getDocId } from '@kbn/discover-utils'; @@ -38,7 +38,7 @@ function getProps() { ariaLabelledBy: '', columns: [], dataView: dataViewMock, - isLoading: false, + loadingState: DataLoadingState.loaded, expandedDoc: undefined, onAddColumn: jest.fn(), onFilter: jest.fn(), diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx index ef9afe2b41822..414b8986e5cbc 100644 --- a/src/plugins/discover/public/components/discover_grid/discover_grid.tsx +++ b/src/plugins/discover/public/components/discover_grid/discover_grid.tsx @@ -56,11 +56,18 @@ import { import { GRID_STYLE, toolbarVisibility as toolbarVisibilityDefaults } from './constants'; import { getDisplayedColumns } from '../../utils/columns'; import { DiscoverGridDocumentToolbarBtn } from './discover_grid_document_selection'; +import { DiscoverGridFooter } from './discover_grid_footer'; import type { ValueToStringConverter } from '../../types'; import { useRowHeightsOptions } from '../../hooks/use_row_heights_options'; import { convertValueToString } from '../../utils/convert_value_to_string'; import { getRowsPerPageOptions, getDefaultRowsPerPage } from '../../utils/rows_per_page'; +export enum DataLoadingState { + loading = 'loading', + loadingMore = 'loadingMore', + loaded = 'loaded', +} + const themeDefault = { darkMode: false }; interface SortObj { @@ -92,7 +99,7 @@ export interface DiscoverGridProps { /** * Determines if data is currently loaded */ - isLoading: boolean; + loadingState: DataLoadingState; /** * Function used to add a column in the document flyout */ @@ -225,7 +232,16 @@ export interface DiscoverGridProps { dataViewFieldEditor: DataViewFieldEditorStart; toastNotifications: ToastsStart; }; + /** + * Number total hits from ES + */ + totalHits?: number; + /** + * To fetch more + */ + onFetchMoreRecords?: () => void; } + export const EuiDataGridMemoized = React.memo(EuiDataGrid); const CONTROL_COLUMN_IDS_DEFAULT = ['openDetails', 'select']; @@ -234,7 +250,7 @@ export const DiscoverGrid = ({ ariaLabelledBy, columns, dataView, - isLoading, + loadingState, expandedDoc, onAddColumn, filters, @@ -268,6 +284,8 @@ export const DiscoverGrid = ({ onFieldEdited, DocumentView, services, + totalHits, + onFetchMoreRecords, }: DiscoverGridProps) => { const { fieldFormats, toastNotifications, dataViewFieldEditor, uiSettings } = services; const { darkMode } = useObservable(services.core.theme?.theme$ ?? of(themeDefault), themeDefault); @@ -358,8 +376,6 @@ export const DiscoverGrid = ({ : undefined; }, [pagination, pageCount, isPaginationEnabled, onUpdateRowsPerPage]); - const isOnLastPage = paginationObj ? paginationObj.pageIndex === pageCount - 1 : false; - useEffect(() => { setPagination((paginationData) => paginationData.pageSize === currentPageSize @@ -413,7 +429,6 @@ export const DiscoverGrid = ({ /** * Render variables */ - const showDisclaimer = rowCount === sampleSize && isOnLastPage; const randomId = useMemo(() => htmlIdGenerator()(), []); const closeFieldEditor = useRef<() => void | undefined>(); @@ -602,7 +617,9 @@ export const DiscoverGrid = ({ onUpdateRowHeight, }); - if (!rowCount && isLoading) { + const isRenderComplete = loadingState !== DataLoadingState.loading; + + if (!rowCount && loadingState === DataLoadingState.loading) { return (
@@ -618,7 +635,7 @@ export const DiscoverGrid = ({ return (
- {showDisclaimer && ( -

- -

- )} + )} {searchTitle && (

diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_footer.test.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_footer.test.tsx new file mode 100644 index 0000000000000..a4bfe84b68126 --- /dev/null +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_footer.test.tsx @@ -0,0 +1,138 @@ +/* + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { findTestSubject } from '@elastic/eui/lib/test'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { DiscoverGridFooter } from './discover_grid_footer'; +import { discoverServiceMock } from '../../__mocks__/services'; + +describe('DiscoverGridFooter', function () { + it('should not render anything when not on the last page', async () => { + const component = mountWithIntl( + + + + ); + expect(component.isEmptyRender()).toBe(true); + }); + + it('should not render anything yet when all rows shown', async () => { + const component = mountWithIntl( + + + + ); + expect(component.isEmptyRender()).toBe(true); + }); + + it('should render a message for the last page', async () => { + const component = mountWithIntl( + + + + ); + expect(findTestSubject(component, 'discoverTableFooter').text()).toBe( + 'Search results are limited to 500 documents. Add more search terms to narrow your search.' + ); + expect(findTestSubject(component, 'dscGridSampleSizeFetchMoreLink').exists()).toBe(false); + }); + + it('should render a message and the button for the last page', async () => { + const mockLoadMore = jest.fn(); + + const component = mountWithIntl( + + + + ); + expect(findTestSubject(component, 'discoverTableFooter').text()).toBe( + 'Search results are limited to 500 documents.Load more' + ); + + const button = findTestSubject(component, 'dscGridSampleSizeFetchMoreLink'); + expect(button.exists()).toBe(true); + + button.simulate('click'); + + expect(mockLoadMore).toHaveBeenCalledTimes(1); + }); + + it('should render a disabled button when loading more', async () => { + const mockLoadMore = jest.fn(); + + const component = mountWithIntl( + + + + ); + expect(findTestSubject(component, 'discoverTableFooter').text()).toBe( + 'Search results are limited to 500 documents.Load more' + ); + + const button = findTestSubject(component, 'dscGridSampleSizeFetchMoreLink'); + expect(button.exists()).toBe(true); + expect(button.prop('disabled')).toBe(true); + + button.simulate('click'); + + expect(mockLoadMore).not.toHaveBeenCalled(); + }); + + it('should render a message when max total limit is reached', async () => { + const component = mountWithIntl( + + + + ); + expect(findTestSubject(component, 'discoverTableFooter').text()).toBe( + 'Search results are limited to 10000 documents. Add more search terms to narrow your search.' + ); + }); +}); diff --git a/src/plugins/discover/public/components/discover_grid/discover_grid_footer.tsx b/src/plugins/discover/public/components/discover_grid/discover_grid_footer.tsx new file mode 100644 index 0000000000000..540c7102bd424 --- /dev/null +++ b/src/plugins/discover/public/components/discover_grid/discover_grid_footer.tsx @@ -0,0 +1,161 @@ +/* + * 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, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiButtonEmpty, EuiToolTip, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; +import { i18n } from '@kbn/i18n'; +import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types'; +import { MAX_LOADED_GRID_ROWS } from '../../../common/constants'; +import { useDiscoverServices } from '../../hooks/use_discover_services'; + +export interface DiscoverGridFooterProps { + isLoadingMore?: boolean; + rowCount: number; + sampleSize: number; + pageIndex?: number; // starts from 0 + pageCount: number; + totalHits?: number; + onFetchMoreRecords?: () => void; +} + +export const DiscoverGridFooter: React.FC = (props) => { + const { + isLoadingMore, + rowCount, + sampleSize, + pageIndex, + pageCount, + totalHits = 0, + onFetchMoreRecords, + } = props; + const { data } = useDiscoverServices(); + const timefilter = data.query.timefilter.timefilter; + const [refreshInterval, setRefreshInterval] = useState(timefilter.getRefreshInterval()); + + useEffect(() => { + const subscriber = timefilter.getRefreshIntervalUpdate$().subscribe(() => { + setRefreshInterval(timefilter.getRefreshInterval()); + }); + + return () => subscriber.unsubscribe(); + }, [timefilter, setRefreshInterval]); + + const isRefreshIntervalOn = Boolean( + refreshInterval && refreshInterval.pause === false && refreshInterval.value > 0 + ); + + const { euiTheme } = useEuiTheme(); + const isOnLastPage = pageIndex === pageCount - 1 && rowCount < totalHits; + + if (!isOnLastPage) { + return null; + } + + // allow to fetch more records on Discover page + if (onFetchMoreRecords && typeof isLoadingMore === 'boolean') { + if (rowCount <= MAX_LOADED_GRID_ROWS - sampleSize) { + return ( + + + + + + + + ); + } + + return ; + } + + if (rowCount < totalHits) { + // show only a message for embeddable + return ; + } + + return null; +}; + +interface DiscoverGridFooterContainerProps extends DiscoverGridFooterProps { + hasButton: boolean; +} + +const DiscoverGridFooterContainer: React.FC = ({ + hasButton, + rowCount, + children, +}) => { + const { euiTheme } = useEuiTheme(); + const { fieldFormats } = useDiscoverServices(); + + const formattedRowCount = fieldFormats + .getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER]) + .convert(rowCount); + + return ( +

+ + {hasButton ? ( + + ) : ( + + )} + + {children} +

+ ); +}; 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 6901df855984e..fbe8ed083ebc3 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 @@ -21,7 +21,7 @@ import { useDiscoverServices } from '../../hooks/use_discover_services'; import { SavedSearchEmbeddableBase } from '../../embeddable/saved_search_embeddable_base'; export interface DocTableEmbeddableProps extends DocTableProps { - totalHitCount: number; + totalHitCount?: number; rowsPerPageState?: number; interceptedWarnings?: SearchResponseInterceptedWarning[]; onUpdateRowsPerPage?: (rowsPerPage?: number) => void; @@ -79,7 +79,7 @@ export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => { ); const shouldShowLimitedResultsWarning = useMemo( - () => !hasNextPage && props.rows.length < props.totalHitCount, + () => !hasNextPage && props.totalHitCount && props.rows.length < props.totalHitCount, [hasNextPage, props.rows.length, props.totalHitCount] ); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index 23e22de975ec3..b3be230355e5d 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -276,7 +276,7 @@ export class SavedSearchEmbeddable useNewFieldsApi, { sampleSize: this.services.uiSettings.get(SAMPLE_SIZE_SETTING), - defaultSort: this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + sortDir: this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING), } ); diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx index b41c70676c754..6c05451ec5a79 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable_base.tsx @@ -20,7 +20,7 @@ const containerStyles = css` export interface SavedSearchEmbeddableBaseProps { isLoading: boolean; - totalHitCount: number; + totalHitCount?: number; prepend?: React.ReactElement; append?: React.ReactElement; dataTestSubj?: string; @@ -55,7 +55,7 @@ export const SavedSearchEmbeddableBase: React.FC > {Boolean(prepend) && {prepend}} - {Boolean(totalHitCount) && ( + {!!totalHitCount && ( 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 03ab602cd760a..fd28f3114211f 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable_component.tsx @@ -8,7 +8,11 @@ import React from 'react'; import { AggregateQuery, Query } from '@kbn/es-query'; -import { DiscoverGridEmbeddable, DiscoverGridEmbeddableProps } from './saved_search_grid'; +import { + DiscoverGridEmbeddable, + DiscoverGridEmbeddableProps, + DataLoadingState, +} from './saved_search_grid'; import { DiscoverDocTableEmbeddable } from '../components/doc_table/create_doc_table_embeddable'; import { DocTableEmbeddableProps } from '../components/doc_table/doc_table_embeddable'; import { isTextBasedQuery } from '../application/main/utils/is_text_based_query'; @@ -32,14 +36,15 @@ export function SavedSearchEmbeddableComponent({ const isPlainRecord = isTextBasedQuery(query); return ( ); } return ( - { const defaults = { sampleSize: 50, - defaultSort: 'asc', + sortDir: 'asc', }; it('updates a given search source', async () => { @@ -30,4 +40,34 @@ describe('updateSearchSource', () => { expect(searchSource.getField('fields')).toEqual([{ field: '*', include_unmapped: 'true' }]); expect(searchSource.getField('fieldsFromSource')).toBe(undefined); }); + + it('updates a given search source with sort field', async () => { + const searchSource1 = createSearchSourceMock({}); + updateSearchSource(searchSource1, dataViewMock, [] as SortOrder[], true, defaults); + expect(searchSource1.getField('sort')).toEqual([{ _score: 'asc' }]); + + const searchSource2 = createSearchSourceMock({}); + updateSearchSource(searchSource2, dataViewMockWithTimeField, [] as SortOrder[], true, { + sampleSize: 50, + sortDir: 'desc', + }); + expect(searchSource2.getField('sort')).toEqual([{ _doc: 'desc' }]); + + const searchSource3 = createSearchSourceMock({}); + updateSearchSource( + searchSource3, + dataViewMockWithTimeField, + [['bytes', 'desc']] as SortOrder[], + true, + defaults + ); + expect(searchSource3.getField('sort')).toEqual([ + { + bytes: 'desc', + }, + { + _doc: 'desc', + }, + ]); + }); }); 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 87ee137aed796..0215a26e649b0 100644 --- a/src/plugins/discover/public/embeddable/utils/update_search_source.ts +++ b/src/plugins/discover/public/embeddable/utils/update_search_source.ts @@ -17,12 +17,20 @@ export const updateSearchSource = ( useNewFieldsApi: boolean, defaults: { sampleSize: number; - defaultSort: string; + sortDir: string; } ) => { - const { sampleSize, defaultSort } = defaults; + const { sampleSize, sortDir } = defaults; searchSource.setField('size', sampleSize); - searchSource.setField('sort', getSortForSearchSource(sort, dataView, defaultSort)); + searchSource.setField( + 'sort', + getSortForSearchSource({ + sort, + dataView, + defaultSortDir: sortDir, + includeTieBreaker: true, + }) + ); if (useNewFieldsApi) { searchSource.removeField('fieldsFromSource'); const fields: Record = { field: '*', include_unmapped: 'true' }; diff --git a/src/plugins/discover/public/utils/get_sharing_data.ts b/src/plugins/discover/public/utils/get_sharing_data.ts index 3334ecd7220c4..9074d9d681f7e 100644 --- a/src/plugins/discover/public/utils/get_sharing_data.ts +++ b/src/plugins/discover/public/utils/get_sharing_data.ts @@ -35,14 +35,18 @@ export async function getSharingData( services: { uiSettings: IUiSettingsClient; data: DataPublicPluginStart }, isPlainRecord?: boolean ) { - const { uiSettings: config, data } = services; + const { uiSettings, data } = services; const searchSource = currentSearchSource.createCopy(); const index = searchSource.getField('index')!; let existingFilter = searchSource.getField('filter') as Filter[] | Filter | undefined; searchSource.setField( 'sort', - getSortForSearchSource(state.sort as SortOrder[], index, config.get(SORT_DEFAULT_ORDER_SETTING)) + getSortForSearchSource({ + sort: state.sort as SortOrder[], + dataView: index, + defaultSortDir: uiSettings.get(SORT_DEFAULT_ORDER_SETTING), + }) ); searchSource.removeField('filter'); @@ -57,7 +61,7 @@ export async function getSharingData( if (columns && columns.length > 0) { // conditionally add the time field column: let timeFieldName: string | undefined; - const hideTimeColumn = config.get(DOC_HIDE_TIME_COLUMN_SETTING); + const hideTimeColumn = uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING); if (!hideTimeColumn && index && index.timeFieldName && !isPlainRecord) { timeFieldName = index.timeFieldName; } @@ -98,7 +102,7 @@ export async function getSharingData( * Otherwise, the requests will ask for all fields, even if only a few are really needed. * Discover does not set fields, since having all fields is needed for the UI. */ - const useFieldsApi = !config.get(SEARCH_FIELDS_FROM_SOURCE); + const useFieldsApi = !uiSettings.get(SEARCH_FIELDS_FROM_SOURCE); if (useFieldsApi) { searchSource.removeField('fieldsFromSource'); const fields = columns.length diff --git a/src/plugins/discover/server/locator/searchsource_from_locator.ts b/src/plugins/discover/server/locator/searchsource_from_locator.ts index 455efc968b534..70a723ddb5c54 100644 --- a/src/plugins/discover/server/locator/searchsource_from_locator.ts +++ b/src/plugins/discover/server/locator/searchsource_from_locator.ts @@ -11,6 +11,7 @@ import { DataView } from '@kbn/data-views-plugin/common'; import { AggregateQuery, Filter, Query } from '@kbn/es-query'; import { SavedSearch } from '@kbn/saved-search-plugin/common'; import { getSavedSearch } from '@kbn/saved-search-plugin/server'; +import { SORT_DEFAULT_ORDER_SETTING } from '@kbn/discover-utils'; import { LocatorServicesDeps } from '.'; import { DiscoverAppLocatorParams } from '../../common'; import { getSortForSearchSource } from '../../common/utils/sorting'; @@ -147,7 +148,13 @@ export function searchSourceFromLocatorFactory(services: LocatorServicesDeps) { // Inject sort if (savedSearch.sort) { - const sort = getSortForSearchSource(savedSearch.sort as Array<[string, string]>, index); + const defaultSortDir = await services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING); + + const sort = getSortForSearchSource({ + sort: savedSearch.sort as Array<[string, string]>, + dataView: index, + defaultSortDir, + }); searchSource.setField('sort', sort); } diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 99748053b958e..bd06f793a6447 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -199,6 +199,7 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record', }, }), + requiresPageReload: true, category: ['discover'], schema: schema.boolean(), metric: { @@ -206,7 +207,6 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record { + 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 esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.uiSettings.replace({}); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await kibanaServer.uiSettings.update(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should show footer only for the last page and allow to load more', async () => { + // footer is not shown + await testSubjects.missingOrFail(FOOTER_SELECTOR); + + // go to next page + await testSubjects.click('pagination-button-next'); + // footer is not shown yet + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + // go to the last page + await testSubjects.click('pagination-button-4'); + // footer is shown now + await retry.try(async function () { + await testSubjects.existOrFail(FOOTER_SELECTOR); + }); + expect(await testSubjects.getVisibleText(FOOTER_SELECTOR)).to.be( + 'Search results are limited to 500 documents.\nLoad more' + ); + + // there is no other pages to see + await testSubjects.missingOrFail('pagination-button-5'); + + // press "Load more" + await testSubjects.click(LOAD_MORE_SELECTOR); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + // more pages appeared and the footer is gone + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + // go to the last page + await testSubjects.click('pagination-button-9'); + expect(await testSubjects.getVisibleText(FOOTER_SELECTOR)).to.be( + 'Search results are limited to 1,000 documents.\nLoad more' + ); + + // press "Load more" + await testSubjects.click(LOAD_MORE_SELECTOR); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + // more pages appeared and the footer is gone + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + // go to the last page + await testSubjects.click('pagination-button-14'); + expect(await testSubjects.getVisibleText(FOOTER_SELECTOR)).to.be( + 'Search results are limited to 1,500 documents.\nLoad more' + ); + }); + + it('should disable "Load more" button when refresh interval is on', async () => { + // go to the last page + await testSubjects.click('pagination-button-4'); + await retry.try(async function () { + await testSubjects.existOrFail(FOOTER_SELECTOR); + }); + + expect(await testSubjects.isEnabled(LOAD_MORE_SELECTOR)).to.be(true); + + // enable the refresh interval + await PageObjects.timePicker.startAutoRefresh(10); + + // the button is disabled now + await retry.waitFor('disabled state', async function () { + return (await testSubjects.isEnabled(LOAD_MORE_SELECTOR)) === false; + }); + + // disable the refresh interval + await PageObjects.timePicker.pauseAutoRefresh(); + + // the button is enabled again + await retry.waitFor('enabled state', async function () { + return (await testSubjects.isEnabled(LOAD_MORE_SELECTOR)) === true; + }); + }); + }); + + describe('time field with date nano type', function () { + before(async () => { + await security.testUser.setRoles(['kibana_admin', 'kibana_date_nanos']); + await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/date_nanos'); + await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/date_nanos'); + }); + + after(async () => { + await esArchiver.unload('test/functional/fixtures/es_archiver/date_nanos'); + await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/date_nanos'); + await kibanaServer.savedObjects.cleanStandardList(); + await kibanaServer.uiSettings.replace({}); + }); + + beforeEach(async function () { + await PageObjects.common.setTime({ + from: 'Sep 10, 2015 @ 00:00:00.000', + to: 'Sep 30, 2019 @ 00:00:00.000', + }); + await kibanaServer.uiSettings.update({ + defaultIndex: 'date-nanos', + 'discover:sampleSize': 4, + 'discover:sampleRowsPerPage': 2, + }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should work for date nanos too', async () => { + await PageObjects.unifiedFieldList.clickFieldListItemAdd('_id'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + expect(await dataGrid.getRowsText()).to.eql([ + 'Sep 22, 2019 @ 23:50:13.253123345AU_x3-TaGFA8no6QjiSJ', + 'Sep 18, 2019 @ 06:50:13.000000104AU_x3-TaGFA8no6Qjis104Z', + ]); + + // footer is not shown + await testSubjects.missingOrFail(FOOTER_SELECTOR); + + // go to the last page + await testSubjects.click('pagination-button-1'); + await retry.try(async function () { + await testSubjects.existOrFail(FOOTER_SELECTOR); + }); + expect(await testSubjects.getVisibleText(FOOTER_SELECTOR)).to.be( + 'Search results are limited to 4 documents.\nLoad more' + ); + expect(await dataGrid.getRowsText()).to.eql([ + 'Sep 18, 2019 @ 06:50:13.000000103BU_x3-TaGFA8no6Qjis103Z', + 'Sep 18, 2019 @ 06:50:13.000000102AU_x3-TaGFA8no6Qji102Z', + ]); + + // there is no other pages to see yet + await testSubjects.missingOrFail('pagination-button-2'); + + // press "Load more" + await testSubjects.click(LOAD_MORE_SELECTOR); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + // more pages appeared and the footer is gone + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + // go to the last page + await testSubjects.click('pagination-button-3'); + expect(await testSubjects.getVisibleText(FOOTER_SELECTOR)).to.be( + 'Search results are limited to 8 documents.\nLoad more' + ); + + expect(await dataGrid.getRowsText()).to.eql([ + 'Sep 18, 2019 @ 06:50:13.000000000CU_x3-TaGFA8no6QjiSX000Z', + 'Sep 18, 2019 @ 06:50:12.999999999AU_x3-TaGFA8no6Qj999Z', + ]); + + // press "Load more" + await testSubjects.click(LOAD_MORE_SELECTOR); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + // more pages appeared and the footer is gone + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + // go to the last page + await testSubjects.click('pagination-button-4'); + await retry.try(async function () { + await testSubjects.missingOrFail(FOOTER_SELECTOR); + }); + + expect(await dataGrid.getRowsText()).to.eql([ + 'Sep 19, 2015 @ 06:50:13.000100001AU_x3-TaGFA8no000100001Z', + ]); + }); + }); + }); +} diff --git a/test/functional/apps/discover/group2/index.ts b/test/functional/apps/discover/group2/index.ts index d6a0aeb9cd9ec..17562157f444e 100644 --- a/test/functional/apps/discover/group2/index.ts +++ b/test/functional/apps/discover/group2/index.ts @@ -30,6 +30,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_pagination')); + loadTestFile(require.resolve('./_data_grid_footer')); loadTestFile(require.resolve('./_adhoc_data_views')); loadTestFile(require.resolve('./_sql_view')); loadTestFile(require.resolve('./_indexpattern_with_unmapped_fields'));