[OnWeek][Discover] Allow to fetch more documents on Discover page (#163784)

> [!WARNING]
> Sorry, I had to recreate the PR
https://github.com/elastic/kibana/pull/157241
> Please submit your review again.

- Closes https://github.com/elastic/kibana/issues/155019

Per docs
https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html
<img width="851" alt="Screenshot 2023-05-10 at 10 25 20"
src="b4b9fef4-7dd8-40ed-8244-343889fc4367">


## Summary

1. This PR improves `search_after` pagination for `date_nanos` time
fields. `sort` value will be returned from ES as a string instead of a
rounded and incorrect timestamp. This change allows to also simplify
logic on Surrounding document page.

Before:
<img width="400" alt="Screenshot 2023-05-08 at 17 36 19"
src="fd9f45c4-5dc2-4103-83b9-8810e3a6e0df">

After:
<img width="400" alt="Screenshot 2023-05-08 at 17 37 13"
src="fe9090c0-2116-4f77-9a57-a96ae6b00365">

2. Also in this PR we now allow users to load more documents within the
same time range. Once the button is pressed, it will load next portion
of documents (same "sampleSize" value will be used). Currently, we limit
max total loaded documents to 10000.

"Load more" demo:
![Aug-07-2023
16-23-28](53af9809-75cb-4b8a-8e99-d8f6d76b4981)

If refresh interval is on, the button becomes disabled:
![Aug-07-2023
16-24-58](85db6144-98eb-40b5-ac88-80ea728bcd6b)

Date nanos demo:
![Aug-07-2023
16-34-59](dc9fe0b1-e419-4c76-9fc6-79907b134e58)


100x Flaky test runner
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2801

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Julia Rechkunova 2023-08-17 20:31:21 +02:00 committed by GitHub
parent 8ffbc7164d
commit 110449df5c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1806 additions and 207 deletions

View file

@ -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
}));

View file

@ -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 {

View file

@ -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();
});
});

View file

@ -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<IUiSettingsClient, 'get'>
) {
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];
}

View file

@ -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({
sort: cols,
dataView: stubDataView,
defaultSortDir: 'desc',
})
).toEqual([{ _doc: 'desc' }]);
expect(
getSortForSearchSource({
sort: cols,
dataView: stubDataView,
defaultSortDir: 'asc',
})
).toEqual([{ _doc: 'asc' }]);
expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField)).toEqual([
{ _score: 'desc' },
]);
expect(getSortForSearchSource(cols, stubDataViewWithoutTimeField, 'asc')).toEqual([
{ _score: 'asc' },
]);
expect(
getSortForSearchSource({
sort: cols,
dataView: stubDataViewWithoutTimeField,
defaultSortDir: 'desc',
})
).toEqual([{ _score: 'desc' }]);
expect(
getSortForSearchSource({
sort: cols,
dataView: stubDataViewWithoutTimeField,
defaultSortDir: 'asc',
})
).toEqual([{ _score: 'asc' }]);
});
});

View file

@ -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<string, string>) => {
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<string, string>) => {
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;
}

View file

@ -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}

View file

@ -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,

View file

@ -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,

View file

@ -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];
}

View file

@ -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 },
];
}

View file

@ -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({
<CellActionsProvider
getTriggerCompatibleActions={uiActions.getTriggerCompatibleActions}
>
<DataGridMemoized
<DiscoverGridMemoized
ariaLabelledBy="documentsAriaLabel"
columns={currentColumns}
expandedDoc={expandedDoc}
dataView={dataView}
isLoading={isDataLoading}
loadingState={
isDataLoading
? DataLoadingState.loading
: isMoreDataLoading
? DataLoadingState.loadingMore
: DataLoadingState.loaded
}
rows={rows}
sort={(sort as SortOrder[]) || []}
sampleSize={sampleSize}
@ -277,6 +289,8 @@ function DiscoverDocumentsComponent({
savedSearchId={savedSearch.id}
DocumentView={DiscoverGridFlyout}
services={services}
totalHits={totalHits}
onFetchMoreRecords={onFetchMoreRecords}
/>
</CellActionsProvider>
</div>

View file

@ -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();
});
});
});

View file

@ -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) => {

View file

@ -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();
});
});

View file

@ -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,
};
};

View file

@ -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<DataDocumentsMsg>({
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<DataDocumentsMsg>({
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<DataDocumentsMsg>({
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<DataMainMsg>({ fetchStatus: FetchStatus.PARTIAL });
main$.subscribe((value) => {

View file

@ -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<T extends DataMsg>(
}
}
/**
* 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
*/

View file

@ -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<typeof fetchDocuments>;
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');
});
});

View file

@ -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<DataDocumentsMsg>;
export type DataTotalHits$ = BehaviorSubject<DataTotalHitsMsg>;
export type AvailableFields$ = BehaviorSubject<DataAvailableFieldsMsg>;
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$,

View file

@ -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

View file

@ -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',
},
]);
});
});
});

View file

@ -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<void> {
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 = <T extends DataMsg>(subject: BehaviorSubject<T>, type: string) =>
subject.pipe(map(({ fetchStatus }) => ({ type, fetchStatus })));

View file

@ -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$ = <T>() =>
of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse<SearchResponse<T>>);
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$ = <T>() =>
of({ rawResponse: { hits: { hits } } } as IKibanaSearchResponse<SearchResponse<T>>);
jest.spyOn(searchSourceForLoadMore, 'fetch$');
expect(fetchDocuments(searchSourceForLoadMore, deps)).resolves.toEqual({
records: documents,
});
expect(searchSourceForLoadMore.fetch$ as jest.Mock).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: undefined })
);
});
});

View file

@ -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.',
}),

View file

@ -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];

View file

@ -9,6 +9,7 @@
export enum FetchStatus {
UNINITIALIZED = 'uninitialized',
LOADING = 'loading',
LOADING_MORE = 'loading_more',
PARTIAL = 'partial',
COMPLETE = 'complete',
ERROR = 'error',

View file

@ -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;
}

View file

@ -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(),

View file

@ -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 (
<div className="euiDataGrid__loading">
<EuiText size="xs" color="subdued">
@ -618,7 +635,7 @@ export const DiscoverGrid = ({
return (
<div
className="euiDataGrid__noResults"
data-render-complete={!isLoading}
data-render-complete={isRenderComplete}
data-shared-item=""
data-title={searchTitle}
data-description={searchDescription}
@ -655,7 +672,7 @@ export const DiscoverGrid = ({
<span className="dscDiscoverGrid__inner">
<div
data-test-subj="discoverDocTable"
data-render-complete={!isLoading}
data-render-complete={isRenderComplete}
data-shared-item=""
data-title={searchTitle}
data-description={searchDescription}
@ -682,17 +699,18 @@ export const DiscoverGrid = ({
gridStyle={GRID_STYLE}
/>
</div>
{showDisclaimer && (
<p className="dscDiscoverGrid__footer" data-test-subj="discoverTableFooter">
<FormattedMessage
id="discover.gridSampleSize.limitDescription"
defaultMessage="Search results are limited to {sampleSize} documents. Add more search terms to narrow your search."
values={{
sampleSize,
}}
{loadingState !== DataLoadingState.loading &&
isPaginationEnabled && ( // we hide the footer for Surrounding Documents page
<DiscoverGridFooter
isLoadingMore={loadingState === DataLoadingState.loadingMore}
rowCount={rowCount}
sampleSize={sampleSize}
pageCount={pageCount}
pageIndex={paginationObj?.pageIndex}
totalHits={totalHits}
onFetchMoreRecords={onFetchMoreRecords}
/>
</p>
)}
)}
{searchTitle && (
<EuiScreenReaderOnly>
<p id={String(randomId)}>

View file

@ -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(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={5}
pageIndex={0}
sampleSize={500}
totalHits={1000}
rowCount={500}
/>
</KibanaContextProvider>
);
expect(component.isEmptyRender()).toBe(true);
});
it('should not render anything yet when all rows shown', async () => {
const component = mountWithIntl(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={5}
pageIndex={4}
sampleSize={500}
totalHits={500}
rowCount={500}
/>
</KibanaContextProvider>
);
expect(component.isEmptyRender()).toBe(true);
});
it('should render a message for the last page', async () => {
const component = mountWithIntl(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={5}
pageIndex={4}
sampleSize={500}
totalHits={1000}
rowCount={500}
/>
</KibanaContextProvider>
);
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(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={5}
pageIndex={4}
sampleSize={500}
totalHits={1000}
rowCount={500}
isLoadingMore={false}
onFetchMoreRecords={mockLoadMore}
/>
</KibanaContextProvider>
);
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(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={5}
pageIndex={4}
sampleSize={500}
totalHits={1000}
rowCount={500}
isLoadingMore={true}
onFetchMoreRecords={mockLoadMore}
/>
</KibanaContextProvider>
);
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(
<KibanaContextProvider services={discoverServiceMock}>
<DiscoverGridFooter
pageCount={100}
pageIndex={99}
sampleSize={500}
totalHits={11000}
rowCount={10000}
/>
</KibanaContextProvider>
);
expect(findTestSubject(component, 'discoverTableFooter').text()).toBe(
'Search results are limited to 10000 documents. Add more search terms to narrow your search.'
);
});
});

View file

@ -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<DiscoverGridFooterProps> = (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 (
<DiscoverGridFooterContainer hasButton={true} {...props}>
<EuiToolTip
content={
isRefreshIntervalOn
? i18n.translate('discover.gridSampleSize.fetchMoreLinkDisabledTooltip', {
defaultMessage: 'To load more the refresh interval needs to be disabled first',
})
: undefined
}
>
<EuiButtonEmpty
disabled={isRefreshIntervalOn}
isLoading={isLoadingMore}
flush="both"
data-test-subj="dscGridSampleSizeFetchMoreLink"
onClick={onFetchMoreRecords}
css={css`
margin-left: ${euiTheme.size.xs};
`}
>
<FormattedMessage
id="discover.gridSampleSize.fetchMoreLinkLabel"
defaultMessage="Load more"
/>
</EuiButtonEmpty>
</EuiToolTip>
</DiscoverGridFooterContainer>
);
}
return <DiscoverGridFooterContainer hasButton={false} {...props} />;
}
if (rowCount < totalHits) {
// show only a message for embeddable
return <DiscoverGridFooterContainer hasButton={false} {...props} />;
}
return null;
};
interface DiscoverGridFooterContainerProps extends DiscoverGridFooterProps {
hasButton: boolean;
}
const DiscoverGridFooterContainer: React.FC<DiscoverGridFooterContainerProps> = ({
hasButton,
rowCount,
children,
}) => {
const { euiTheme } = useEuiTheme();
const { fieldFormats } = useDiscoverServices();
const formattedRowCount = fieldFormats
.getDefaultInstance(KBN_FIELD_TYPES.NUMBER, [ES_FIELD_TYPES.INTEGER])
.convert(rowCount);
return (
<p
data-test-subj="discoverTableFooter"
css={css`
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: ${euiTheme.colors.lightestShade};
padding: ${hasButton
? `0 ${euiTheme.size.base}`
: `${euiTheme.size.s} ${euiTheme.size.base}`};
margin-top: ${euiTheme.size.xs};
text-align: center;
`}
>
<span>
{hasButton ? (
<FormattedMessage
id="discover.gridSampleSize.lastPageDescription"
defaultMessage="Search results are limited to {rowCount} documents."
values={{
rowCount: formattedRowCount,
}}
/>
) : (
<FormattedMessage
id="discover.gridSampleSize.limitDescription"
defaultMessage="Search results are limited to {sampleSize} documents. Add more search terms to narrow your search."
values={{
sampleSize: formattedRowCount,
}}
/>
)}
</span>
{children}
</p>
);
};

View file

@ -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]
);

View file

@ -280,7 +280,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),
}
);

View file

@ -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<SavedSearchEmbeddableBaseProps>
>
{Boolean(prepend) && <EuiFlexItem grow={false}>{prepend}</EuiFlexItem>}
{Boolean(totalHitCount) && (
{!!totalHitCount && (
<EuiFlexItem grow={false} data-test-subj="toolBarTotalDocsText">
<TotalDocuments totalHitCount={totalHitCount} />
</EuiFlexItem>

View file

@ -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 (
<DiscoverDocTableEmbeddableMemoized
{...(searchProps as DocTableEmbeddableProps)}
{...(searchProps as DocTableEmbeddableProps)} // TODO later: remove the type casting to prevent unexpected errors due to missing props!
isPlainRecord={isPlainRecord}
/>
);
}
return (
<DiscoverGridEmbeddableMemoized
{...(searchProps as DiscoverGridEmbeddableProps)}
{...(searchProps as DiscoverGridEmbeddableProps)} // TODO later: remove the type casting to prevent unexpected errors due to missing props!
loadingState={searchProps.isLoading ? DataLoadingState.loading : DataLoadingState.loaded}
showFullScreenButton={false}
query={query}
className="dscDiscoverGrid"

View file

@ -5,20 +5,25 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, memo } from 'react';
import React, { memo, useState } from 'react';
import type { DataTableRecord } from '@kbn/discover-utils/types';
import type { SearchResponseInterceptedWarning } from '@kbn/search-response-warnings';
import { DiscoverGrid, DiscoverGridProps } from '../components/discover_grid/discover_grid';
import {
DiscoverGrid,
DiscoverGridProps,
DataLoadingState as DiscoverDataLoadingState,
} from '../components/discover_grid/discover_grid';
import './saved_search_grid.scss';
import { DiscoverGridFlyout } from '../components/discover_grid/discover_grid_flyout';
import { SavedSearchEmbeddableBase } from './saved_search_embeddable_base';
export { DataLoadingState } from '../components/discover_grid/discover_grid';
export interface DiscoverGridEmbeddableProps extends DiscoverGridProps {
totalHitCount: number;
totalHitCount?: number;
interceptedWarnings?: SearchResponseInterceptedWarning[];
}
export const DataGridMemoized = memo(DiscoverGrid);
export const DiscoverGridMemoized = memo(DiscoverGrid);
export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) {
const { interceptedWarnings, ...gridProps } = props;
@ -27,12 +32,13 @@ export function DiscoverGridEmbeddable(props: DiscoverGridEmbeddableProps) {
return (
<SavedSearchEmbeddableBase
totalHitCount={props.totalHitCount}
isLoading={props.isLoading}
isLoading={props.loadingState === DiscoverDataLoadingState.loading}
dataTestSubj="embeddedSavedSearchDocTable"
interceptedWarnings={props.interceptedWarnings}
>
<DataGridMemoized
<DiscoverGridMemoized
{...gridProps}
totalHits={props.totalHitCount}
setExpandedDoc={setExpandedDoc}
expandedDoc={expandedDoc}
DocumentView={DiscoverGridFlyout}

View file

@ -7,13 +7,23 @@
*/
import { createSearchSourceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { updateSearchSource } from './update_search_source';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import {
buildDataViewMock,
dataViewMock,
shallowMockedFields,
} from '@kbn/discover-utils/src/__mocks__';
import type { SortOrder } from '@kbn/saved-search-plugin/public';
const dataViewMockWithTimeField = buildDataViewMock({
name: 'the-data-view',
fields: shallowMockedFields,
timeFieldName: '@timestamp',
});
describe('updateSearchSource', () => {
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',
},
]);
});
});

View file

@ -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<string, string> = { field: '*', include_unmapped: 'true' };

View file

@ -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

View file

@ -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);
}

View file

@ -199,6 +199,7 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record<string, U
'</a>',
},
}),
requiresPageReload: true,
category: ['discover'],
schema: schema.boolean(),
metric: {
@ -206,7 +207,6 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record<string, U
name: 'discover:useLegacyDataGrid',
},
},
[MODIFY_COLUMNS_ON_SWITCH]: {
name: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchTitle', {
defaultMessage: 'Modify columns when changing data views',

View file

@ -65,6 +65,7 @@
"@kbn/core-application-browser",
"@kbn/core-saved-objects-server",
"@kbn/discover-utils",
"@kbn/field-types",
"@kbn/search-response-warnings",
"@kbn/content-management-plugin",
"@kbn/serverless",

View file

@ -0,0 +1,226 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
const FOOTER_SELECTOR = 'discoverTableFooter';
const LOAD_MORE_SELECTOR = 'dscGridSampleSizeFetchMoreLink';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
const dataGrid = getService('dataGrid');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'unifiedFieldList']);
const defaultSettings = { defaultIndex: 'logstash-*' };
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const security = getService('security');
describe('discover data grid footer', function () {
describe('time field with date type', function () {
before(async () => {
await security.testUser.setRoles(['kibana_admin', 'test_logstash_reader']);
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
await kibanaServer.importExport.load('test/functional/fixtures/kbn_archiver/discover');
});
after(async () => {
await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover');
await 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',
]);
});
});
});
}

View file

@ -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'));