[Discover] Fix pagination when applying filter (#110763)

* [Discover] fix pagination when applying filter

* [Discover] refactoring to forward ref usage

* [Discover] remove console log debug

* [Discover] hide pagination on empty result

* [Discover] add usePager test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Dmitry Tomashevich 2021-09-09 15:48:20 +03:00 committed by GitHub
parent a1a717862b
commit 9792c1079e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 396 additions and 294 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { memo, useCallback, useMemo } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import './index.scss';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
@ -24,30 +24,61 @@ export interface DocTableEmbeddableProps extends DocTableProps {
const DocTableWrapperMemoized = memo(DocTableWrapper);
export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => {
const pager = usePager({ totalItems: props.rows.length });
const tableWrapperRef = useRef<HTMLDivElement>(null);
const {
currentPage,
pageSize,
totalPages,
startIndex,
hasNextPage,
changePage,
changePageSize,
} = usePager({
totalItems: props.rows.length,
});
const showPagination = totalPages !== 0;
const pageOfItems = useMemo(
() => props.rows.slice(pager.startIndex, pager.pageSize + pager.startIndex),
[pager.pageSize, pager.startIndex, props.rows]
const scrollTop = useCallback(() => {
if (tableWrapperRef.current) {
tableWrapperRef.current.scrollTo(0, 0);
}
}, []);
const pageOfItems = useMemo(() => props.rows.slice(startIndex, pageSize + startIndex), [
pageSize,
startIndex,
props.rows,
]);
const onPageChange = useCallback(
(page: number) => {
scrollTop();
changePage(page);
},
[changePage, scrollTop]
);
const shouldShowLimitedResultsWarning = () =>
!pager.hasNextPage && props.rows.length < props.totalHitCount;
const onPageSizeChange = useCallback(
(size: number) => {
scrollTop();
changePageSize(size);
},
[changePageSize, scrollTop]
);
const scrollTop = () => {
const scrollDiv = document.querySelector('.kbnDocTableWrapper') as HTMLElement;
scrollDiv.scrollTo(0, 0);
};
/**
* Go to the first page if the current is no longer available
*/
useEffect(() => {
if (totalPages < currentPage + 1) {
onPageChange(0);
}
}, [currentPage, totalPages, onPageChange]);
const onPageChange = (page: number) => {
scrollTop();
pager.onPageChange(page);
};
const onPageSizeChange = (size: number) => {
scrollTop();
pager.onPageSizeChange(size);
};
const shouldShowLimitedResultsWarning = useMemo(
() => !hasNextPage && props.rows.length < props.totalHitCount,
[hasNextPage, props.rows.length, props.totalHitCount]
);
const sampleSize = useMemo(() => {
return getServices().uiSettings.get(SAMPLE_SIZE_SETTING, 500);
@ -77,7 +108,7 @@ export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => {
responsive={false}
wrap={true}
>
{shouldShowLimitedResultsWarning() && (
{shouldShowLimitedResultsWarning && (
<EuiFlexItem grow={false}>
<EuiText grow={false} size="s" color="subdued">
<FormattedMessage
@ -97,18 +128,20 @@ export const DocTableEmbeddable = (props: DocTableEmbeddableProps) => {
</EuiFlexItem>
<EuiFlexItem style={{ minHeight: 0 }}>
<DocTableWrapperMemoized {...props} render={renderDocTable} />
<DocTableWrapperMemoized ref={tableWrapperRef} {...props} render={renderDocTable} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ToolBarPagination
pageSize={pager.pageSize}
pageCount={pager.totalPages}
activePage={pager.currentPage}
onPageClick={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</EuiFlexItem>
{showPagination && (
<EuiFlexItem grow={false}>
<ToolBarPagination
pageSize={pageSize}
pageCount={totalPages}
activePage={currentPage}
onPageClick={onPageChange}
onPageSizeChange={onPageSizeChange}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { Fragment, memo, useCallback, useEffect, useState } from 'react';
import React, { Fragment, memo, useCallback, useEffect, useRef, useState } from 'react';
import './index.scss';
import { FormattedMessage } from '@kbn/i18n/react';
import { debounce } from 'lodash';
@ -17,24 +17,88 @@ import { shouldLoadNextDocPatch } from './lib/should_load_next_doc_patch';
const FOOTER_PADDING = { padding: 0 };
const DocTableInfiniteContent = (props: DocTableRenderProps) => {
const [limit, setLimit] = useState(props.minimumVisibleRows);
const DocTableWrapperMemoized = memo(DocTableWrapper);
// Reset infinite scroll limit
useEffect(() => {
setLimit(props.minimumVisibleRows);
}, [props.rows, props.minimumVisibleRows]);
interface DocTableInfiniteContentProps extends DocTableRenderProps {
limit: number;
onSetMaxLimit: () => void;
onBackToTop: () => void;
}
const DocTableInfiniteContent = ({
rows,
columnLength,
sampleSize,
limit,
onSkipBottomButtonClick,
renderHeader,
renderRows,
onSetMaxLimit,
onBackToTop,
}: DocTableInfiniteContentProps) => {
const onSkipBottomButton = useCallback(() => {
onSetMaxLimit();
onSkipBottomButtonClick();
}, [onSetMaxLimit, onSkipBottomButtonClick]);
return (
<Fragment>
<SkipBottomButton onClick={onSkipBottomButton} />
<table className="kbn-table table" data-test-subj="docTable">
<thead>{renderHeader()}</thead>
<tbody>{renderRows(rows.slice(0, limit))}</tbody>
<tfoot>
<tr>
<td colSpan={(columnLength || 1) + 2} style={FOOTER_PADDING}>
{rows.length === sampleSize ? (
<div
className="kbnDocTable__footer"
data-test-subj="discoverDocTableFooter"
tabIndex={-1}
id="discoverBottomMarker"
>
<FormattedMessage
id="discover.howToSeeOtherMatchingDocumentsDescription"
defaultMessage="These are the first {sampleSize} documents matching
your search, refine your search to see others."
values={{ sampleSize }}
/>
<EuiButtonEmpty onClick={onBackToTop} data-test-subj="discoverBackToTop">
<FormattedMessage
id="discover.backToTopLinkText"
defaultMessage="Back to top."
/>
</EuiButtonEmpty>
</div>
) : (
<span tabIndex={-1} id="discoverBottomMarker">
&#8203;
</span>
)}
</td>
</tr>
</tfoot>
</table>
</Fragment>
);
};
export const DocTableInfinite = (props: DocTableProps) => {
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [limit, setLimit] = useState(50);
/**
* depending on which version of Discover is displayed, different elements are scrolling
* and have therefore to be considered for calculation of infinite scrolling
*/
useEffect(() => {
const scrollDiv = document.querySelector('.kbnDocTableWrapper') as HTMLElement;
// After mounting table wrapper should be initialized
const scrollDiv = tableWrapperRef.current as HTMLDivElement;
const scrollMobileElem = document.documentElement;
const scheduleCheck = debounce(() => {
const isMobileView = document.getElementsByClassName('dscSidebar__mobile').length > 0;
const usedScrollDiv = isMobileView ? scrollMobileElem : scrollDiv;
if (shouldLoadNextDocPatch(usedScrollDiv)) {
setLimit((prevLimit) => prevLimit + 50);
@ -58,63 +122,26 @@ const DocTableInfiniteContent = (props: DocTableRenderProps) => {
focusElem.focus();
// Only the desktop one needs to target a specific container
if (!isMobileView) {
const scrollDiv = document.querySelector('.kbnDocTableWrapper') as HTMLElement;
scrollDiv.scrollTo(0, 0);
if (!isMobileView && tableWrapperRef.current) {
tableWrapperRef.current.scrollTo(0, 0);
} else if (window) {
window.scrollTo(0, 0);
}
}, []);
return (
<Fragment>
<SkipBottomButton onClick={props.onSkipBottomButtonClick} />
<table className="kbn-table table" data-test-subj="docTable">
<thead>{props.renderHeader()}</thead>
<tbody>{props.renderRows(props.rows.slice(0, limit))}</tbody>
<tfoot>
<tr>
<td colSpan={(props.columnLength || 1) + 2} style={FOOTER_PADDING}>
{props.rows.length === props.sampleSize ? (
<div
className="kbnDocTable__footer"
data-test-subj="discoverDocTableFooter"
tabIndex={-1}
id="discoverBottomMarker"
>
<FormattedMessage
id="discover.howToSeeOtherMatchingDocumentsDescription"
defaultMessage="These are the first {sampleSize} documents matching
your search, refine your search to see others."
values={{ sampleSize: props.sampleSize }}
/>
<EuiButtonEmpty onClick={onBackToTop} data-test-subj="discoverBackToTop">
<FormattedMessage
id="discover.backToTopLinkText"
defaultMessage="Back to top."
/>
</EuiButtonEmpty>
</div>
) : (
<span tabIndex={-1} id="discoverBottomMarker">
&#8203;
</span>
)}
</td>
</tr>
</tfoot>
</table>
</Fragment>
const setMaxLimit = useCallback(() => setLimit(props.rows.length), [props.rows.length]);
const renderDocTable = useCallback(
(tableProps: DocTableRenderProps) => (
<DocTableInfiniteContent
{...tableProps}
limit={limit}
onSetMaxLimit={setMaxLimit}
onBackToTop={onBackToTop}
/>
),
[limit, onBackToTop, setMaxLimit]
);
};
const DocTableWrapperMemoized = memo(DocTableWrapper);
const DocTableInfiniteContentMemoized = memo(DocTableInfiniteContent);
const renderDocTable = (tableProps: DocTableRenderProps) => (
<DocTableInfiniteContentMemoized {...tableProps} />
);
export const DocTableInfinite = (props: DocTableProps) => {
return <DocTableWrapperMemoized {...props} render={renderDocTable} />;
return <DocTableWrapperMemoized ref={tableWrapperRef} render={renderDocTable} {...props} />;
};

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { useCallback, useMemo, useState } from 'react';
import React, { forwardRef, useCallback, useMemo } from 'react';
import { EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common';
import { FormattedMessage } from '@kbn/i18n/react';
@ -86,7 +86,6 @@ export interface DocTableProps {
export interface DocTableRenderProps {
columnLength: number;
rows: DocTableRow[];
minimumVisibleRows: number;
sampleSize: number;
renderRows: (row: DocTableRow[]) => JSX.Element[];
renderHeader: () => JSX.Element;
@ -100,163 +99,166 @@ export interface DocTableWrapperProps extends DocTableProps {
render: (params: DocTableRenderProps) => JSX.Element;
}
export const DocTableWrapper = ({
render,
columns,
rows,
indexPattern,
onSort,
onAddColumn,
onMoveColumn,
onRemoveColumn,
sort,
onFilter,
useNewFieldsApi,
searchDescription,
sharedItemTitle,
dataTestSubj,
isLoading,
}: DocTableWrapperProps) => {
const [minimumVisibleRows, setMinimumVisibleRows] = useState(50);
const [
defaultSortOrder,
hideTimeColumn,
isShortDots,
sampleSize,
showMultiFields,
filterManager,
addBasePath,
] = useMemo(() => {
const services = getServices();
return [
services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc'),
services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false),
services.uiSettings.get(FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE),
services.uiSettings.get(SAMPLE_SIZE_SETTING, 500),
services.uiSettings.get(SHOW_MULTIFIELDS, false),
services.filterManager,
services.addBasePath,
];
}, []);
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const onSkipBottomButtonClick = useCallback(async () => {
// delay scrolling to after the rows have been rendered
const bottomMarker = document.getElementById('discoverBottomMarker');
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// show all the rows
setMinimumVisibleRows(rows.length);
while (rows.length !== document.getElementsByClassName('kbnDocTable__row').length) {
await wait(50);
}
bottomMarker!.focus();
await wait(50);
bottomMarker!.blur();
}, [setMinimumVisibleRows, rows]);
const fieldsToShow = useMemo(
() =>
getFieldsToShow(
indexPattern.fields.map((field: IndexPatternField) => field.name),
indexPattern,
showMultiFields
),
[indexPattern, showMultiFields]
);
const renderHeader = useCallback(
() => (
<TableHeader
columns={columns}
defaultSortOrder={defaultSortOrder}
hideTimeColumn={hideTimeColumn}
indexPattern={indexPattern}
isShortDots={isShortDots}
onChangeSortOrder={onSort}
onMoveColumn={onMoveColumn}
onRemoveColumn={onRemoveColumn}
sortOrder={sort as SortOrder[]}
/>
),
[
export const DocTableWrapper = forwardRef(
(
{
render,
columns,
defaultSortOrder,
hideTimeColumn,
rows,
indexPattern,
isShortDots,
onSort,
onAddColumn,
onMoveColumn,
onRemoveColumn,
onSort,
sort,
]
);
const renderRows = useCallback(
(rowsToRender: DocTableRow[]) => {
return rowsToRender.map((current) => (
<TableRow
key={`${current._index}${current._type ?? ''}${current._id}${current._score}${
current._version
}${current._routing}`}
columns={columns}
filter={onFilter}
indexPattern={indexPattern}
row={current}
useNewFieldsApi={useNewFieldsApi}
hideTimeColumn={hideTimeColumn}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filterManager={filterManager}
addBasePath={addBasePath}
fieldsToShow={fieldsToShow}
/>
));
},
[
columns,
onFilter,
indexPattern,
useNewFieldsApi,
searchDescription,
sharedItemTitle,
dataTestSubj,
isLoading,
}: DocTableWrapperProps,
ref
) => {
const [
defaultSortOrder,
hideTimeColumn,
onAddColumn,
onRemoveColumn,
isShortDots,
sampleSize,
showMultiFields,
filterManager,
addBasePath,
fieldsToShow,
]
);
] = useMemo(() => {
const services = getServices();
return [
services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc'),
services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false),
services.uiSettings.get(FORMATS_UI_SETTINGS.SHORT_DOTS_ENABLE),
services.uiSettings.get(SAMPLE_SIZE_SETTING, 500),
services.uiSettings.get(SHOW_MULTIFIELDS, false),
services.filterManager,
services.addBasePath,
];
}, []);
return (
<div
className="kbnDocTableWrapper eui-yScroll eui-xScroll"
data-shared-item
data-title={sharedItemTitle}
data-description={searchDescription}
data-test-subj={dataTestSubj}
data-render-complete={!isLoading}
>
{rows.length !== 0 &&
render({
columnLength: columns.length,
rows,
minimumVisibleRows,
sampleSize,
onSkipBottomButtonClick,
renderHeader,
renderRows,
})}
{!rows.length && (
<div className="kbnDocTable__error">
<EuiText size="xs" color="subdued">
<EuiIcon type="visualizeApp" size="m" color="subdued" />
<EuiSpacer size="m" />
<FormattedMessage
id="discover.docTable.noResultsTitle"
defaultMessage="No results found"
/>
</EuiText>
</div>
)}
</div>
);
};
const onSkipBottomButtonClick = useCallback(async () => {
// delay scrolling to after the rows have been rendered
const bottomMarker = document.getElementById('discoverBottomMarker');
while (rows.length !== document.getElementsByClassName('kbnDocTable__row').length) {
await wait(50);
}
bottomMarker!.focus();
await wait(50);
bottomMarker!.blur();
}, [rows]);
const fieldsToShow = useMemo(
() =>
getFieldsToShow(
indexPattern.fields.map((field: IndexPatternField) => field.name),
indexPattern,
showMultiFields
),
[indexPattern, showMultiFields]
);
const renderHeader = useCallback(
() => (
<TableHeader
columns={columns}
defaultSortOrder={defaultSortOrder}
hideTimeColumn={hideTimeColumn}
indexPattern={indexPattern}
isShortDots={isShortDots}
onChangeSortOrder={onSort}
onMoveColumn={onMoveColumn}
onRemoveColumn={onRemoveColumn}
sortOrder={sort as SortOrder[]}
/>
),
[
columns,
defaultSortOrder,
hideTimeColumn,
indexPattern,
isShortDots,
onMoveColumn,
onRemoveColumn,
onSort,
sort,
]
);
const renderRows = useCallback(
(rowsToRender: DocTableRow[]) => {
return rowsToRender.map((current) => (
<TableRow
key={`${current._index}${current._type ?? ''}${current._id}${current._score}${
current._version
}${current._routing}`}
columns={columns}
filter={onFilter}
indexPattern={indexPattern}
row={current}
useNewFieldsApi={useNewFieldsApi}
hideTimeColumn={hideTimeColumn}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filterManager={filterManager}
addBasePath={addBasePath}
fieldsToShow={fieldsToShow}
/>
));
},
[
columns,
onFilter,
indexPattern,
useNewFieldsApi,
hideTimeColumn,
onAddColumn,
onRemoveColumn,
filterManager,
addBasePath,
fieldsToShow,
]
);
return (
<div
className="kbnDocTableWrapper eui-yScroll eui-xScroll"
data-shared-item
data-title={sharedItemTitle}
data-description={searchDescription}
data-test-subj={dataTestSubj}
data-render-complete={!isLoading}
ref={ref as React.MutableRefObject<HTMLDivElement>}
>
{rows.length !== 0 &&
render({
columnLength: columns.length,
rows,
sampleSize,
onSkipBottomButtonClick,
renderHeader,
renderRows,
})}
{!rows.length && (
<div className="kbnDocTable__error">
<EuiText size="xs" color="subdued">
<EuiIcon type="visualizeApp" size="m" color="subdued" />
<EuiSpacer size="m" />
<FormattedMessage
id="discover.docTable.noResultsTitle"
defaultMessage="No results found"
/>
</EuiText>
</div>
)}
</div>
);
}
);

View file

@ -0,0 +1,75 @@
/*
* 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 { act, renderHook } from '@testing-library/react-hooks';
import { usePager } from './use_pager';
describe('usePager', () => {
const defaultProps = {
totalItems: 745,
};
test('should initialize the first page', () => {
const { result } = renderHook(() => {
return usePager(defaultProps);
});
expect(result.current.currentPage).toEqual(0);
expect(result.current.pageSize).toEqual(50);
expect(result.current.totalPages).toEqual(15);
expect(result.current.startIndex).toEqual(0);
expect(result.current.hasNextPage).toEqual(true);
});
test('should change the page', () => {
const { result } = renderHook(() => {
return usePager(defaultProps);
});
act(() => {
result.current.changePage(5);
});
expect(result.current.currentPage).toEqual(5);
expect(result.current.pageSize).toEqual(50);
expect(result.current.totalPages).toEqual(15);
expect(result.current.startIndex).toEqual(250);
expect(result.current.hasNextPage).toEqual(true);
});
test('should go to the last page', () => {
const { result } = renderHook(() => {
return usePager(defaultProps);
});
act(() => {
result.current.changePage(15);
});
expect(result.current.currentPage).toEqual(15);
expect(result.current.pageSize).toEqual(50);
expect(result.current.totalPages).toEqual(15);
expect(result.current.startIndex).toEqual(750);
expect(result.current.hasNextPage).toEqual(false);
});
test('should change page size and stay on the current page', () => {
const { result } = renderHook(() => usePager(defaultProps));
act(() => {
result.current.changePage(5);
result.current.changePageSize(100);
});
expect(result.current.currentPage).toEqual(5);
expect(result.current.pageSize).toEqual(100);
expect(result.current.totalPages).toEqual(8);
expect(result.current.startIndex).toEqual(500);
expect(result.current.hasNextPage).toEqual(true);
});
});

View file

@ -6,73 +6,38 @@
* Side Public License, v 1.
*/
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
interface MetaParams {
currentPage: number;
totalItems: number;
totalPages: number;
startIndex: number;
hasNextPage: boolean;
pageSize: number;
}
interface ProvidedMeta {
updatedPageSize?: number;
updatedCurrentPage?: number;
}
const INITIAL_PAGE_SIZE = 50;
export const usePager = ({ totalItems }: { totalItems: number }) => {
const [meta, setMeta] = useState<MetaParams>({
currentPage: 0,
totalItems,
startIndex: 0,
totalPages: Math.ceil(totalItems / INITIAL_PAGE_SIZE),
hasNextPage: true,
pageSize: INITIAL_PAGE_SIZE,
});
const [pageSize, setPageSize] = useState(INITIAL_PAGE_SIZE);
const [currentPage, setCurrentPage] = useState(0);
const getNewMeta = useCallback(
(newMeta: ProvidedMeta) => {
const actualCurrentPage = newMeta.updatedCurrentPage ?? meta.currentPage;
const actualPageSize = newMeta.updatedPageSize ?? meta.pageSize;
const meta: MetaParams = useMemo(() => {
const totalPages = Math.ceil(totalItems / pageSize);
return {
totalPages,
startIndex: pageSize * currentPage,
hasNextPage: currentPage + 1 < totalPages,
};
}, [currentPage, pageSize, totalItems]);
const newTotalPages = Math.ceil(totalItems / actualPageSize);
const newStartIndex = actualPageSize * actualCurrentPage;
const changePage = useCallback((pageIndex: number) => setCurrentPage(pageIndex), []);
return {
currentPage: actualCurrentPage,
totalPages: newTotalPages,
startIndex: newStartIndex,
totalItems,
hasNextPage: meta.currentPage + 1 < meta.totalPages,
pageSize: actualPageSize,
};
},
[meta.currentPage, meta.pageSize, meta.totalPages, totalItems]
);
const onPageChange = useCallback(
(pageIndex: number) => setMeta(getNewMeta({ updatedCurrentPage: pageIndex })),
[getNewMeta]
);
const onPageSizeChange = useCallback(
(newPageSize: number) =>
setMeta(getNewMeta({ updatedPageSize: newPageSize, updatedCurrentPage: 0 })),
[getNewMeta]
);
/**
* Update meta on totalItems change
*/
useEffect(() => setMeta(getNewMeta({})), [getNewMeta, totalItems]);
const changePageSize = useCallback((newPageSize: number) => setPageSize(newPageSize), []);
return {
...meta,
onPageChange,
onPageSizeChange,
currentPage,
pageSize,
changePage,
changePageSize,
};
};