Skip to content
64 changes: 30 additions & 34 deletions src/components/PaginatedTable/PaginatedTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';

import {usePaginatedTableState} from './PaginatedTableContext';
import {TableChunk} from './TableChunk';
import {TableHead} from './TableHead';
import {DEFAULT_TABLE_ROW_HEIGHT} from './constants';
import {b} from './shared';
Expand All @@ -15,6 +14,7 @@ import type {
RenderErrorMessage,
} from './types';
import {useScrollBasedChunks} from './useScrollBasedChunks';
import {useVirtualizedTbodies} from './useVirtualizedTbodies';

import './PaginatedTable.scss';

Expand All @@ -36,7 +36,7 @@ export interface PaginatedTableProps<T, F> {
keepCache?: boolean;
}

const DEFAULT_PAGINATION_LIMIT = 20;
const DEFAULT_PAGINATION_LIMIT = 50;

export const PaginatedTable = <T, F>({
limit: chunkSize = DEFAULT_PAGINATION_LIMIT,
Expand All @@ -63,7 +63,7 @@ export const PaginatedTable = <T, F>({

const tableRef = React.useRef<HTMLDivElement>(null);

const activeChunks = useScrollBasedChunks({
const {visibleRowRange, totalItems} = useScrollBasedChunks({
scrollContainerRef,
tableRef,
totalItems: foundEntities,
Expand All @@ -78,15 +78,6 @@ export const PaginatedTable = <T, F>({
setFilters(rawFilters);
}, [rawFilters]);

const lastChunkSize = React.useMemo(() => {
// If foundEntities = 0, there will only first chunk
// Display it with 1 row, to display empty data message
if (!foundEntities) {
return 1;
}
return foundEntities % chunkSize || chunkSize;
}, [foundEntities, chunkSize]);

const handleDataFetched = React.useCallback(
(data?: PaginatedTableData<T>) => {
if (data) {
Expand All @@ -99,6 +90,17 @@ export const PaginatedTable = <T, F>({
[onDataFetched, setFoundEntities, setIsInitialLoad, setTotalEntities],
);

// Set will-change: transform on scroll container if not already set
React.useLayoutEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const computedStyle = window.getComputedStyle(scrollContainer);
if (computedStyle.willChange !== 'transform') {
scrollContainer.style.willChange = 'transform';
}
}
}, [scrollContainerRef.current]);

// Reset table on initialization and filters change
React.useLayoutEffect(() => {
const defaultTotal = initialEntitiesCount || 0;
Expand All @@ -109,28 +111,22 @@ export const PaginatedTable = <T, F>({
setIsInitialLoad(true);
}, [initialEntitiesCount, setTotalEntities, setFoundEntities, setIsInitialLoad]);

const renderChunks = () => {
return activeChunks.map((isActive, index) => (
<TableChunk<T, F>
key={index}
id={index}
calculatedCount={index === activeChunks.length - 1 ? lastChunkSize : chunkSize}
chunkSize={chunkSize}
rowHeight={rowHeight}
columns={columns}
fetchData={fetchData}
filters={filters}
tableName={tableName}
sortParams={sortParams}
getRowClassName={getRowClassName}
renderErrorMessage={renderErrorMessage}
renderEmptyDataMessage={renderEmptyDataMessage}
onDataFetched={handleDataFetched}
isActive={isActive}
keepCache={keepCache}
/>
));
};
const {renderChunks} = useVirtualizedTbodies({
visibleRowRange,
totalItems,
chunkSize,
rowHeight,
columns,
fetchData,
filters,
tableName,
sortParams,
getRowClassName,
renderErrorMessage,
renderEmptyDataMessage,
onDataFetched: handleDataFetched,
keepCache,
});

const renderTable = () => (
<table className={b('table')}>
Expand Down
93 changes: 48 additions & 45 deletions src/components/PaginatedTable/TableChunk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ interface TableChunkProps<T, F> {
columns: Column<T>[];
filters?: F;
sortParams?: SortParams;
isActive: boolean;
tableName: string;
startRow: number;
endRow: number;

fetchData: FetchData<T, F>;
getRowClassName?: GetRowClassName<T>;
Expand All @@ -56,8 +57,9 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
renderErrorMessage,
renderEmptyDataMessage,
onDataFetched,
isActive,
keepCache,
startRow,
endRow,
}: TableChunkProps<T, F>) {
const [isTimeoutActive, setIsTimeoutActive] = React.useState(true);
const [autoRefreshInterval] = useAutoRefreshInterval();
Expand All @@ -75,7 +77,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
};

tableDataApi.useFetchTableChunkQuery(queryParams, {
skip: isTimeoutActive || !isActive,
skip: isTimeoutActive,
pollingInterval: autoRefreshInterval,
refetchOnMountOrArgChange: !keepCache,
});
Expand All @@ -85,7 +87,7 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
React.useEffect(() => {
let timeout = 0;

if (isActive && isTimeoutActive) {
if (isTimeoutActive) {
timeout = window.setTimeout(() => {
setIsTimeoutActive(false);
}, DEBOUNCE_TIMEOUT);
Expand All @@ -94,77 +96,78 @@ export const TableChunk = typedMemo(function TableChunk<T, F>({
return () => {
window.clearTimeout(timeout);
};
}, [isActive, isTimeoutActive]);
}, [isTimeoutActive]);

React.useEffect(() => {
if (currentData && isActive) {
if (currentData) {
onDataFetched({
...currentData,
data: currentData.data as T[],
found: currentData.found || 0,
total: currentData.total || 0,
});
}
}, [currentData, isActive, onDataFetched]);
}, [currentData, onDataFetched]);

const dataLength = currentData?.data?.length || calculatedCount;

const renderContent = () => {
if (!isActive) {
return null;
}

if (!currentData) {
if (error) {
const errorData = error as IResponseError;
return (
<EmptyTableRow columns={columns}>
return [
<EmptyTableRow key="empty" columns={columns}>
{renderErrorMessage ? (
renderErrorMessage(errorData)
) : (
<ResponseError error={errorData} />
)}
</EmptyTableRow>
);
</EmptyTableRow>,
];
} else {
return getArray(dataLength).map((value) => (
<LoadingTableRow key={value} columns={columns} height={rowHeight} />
));
return getArray(dataLength)
.map((value, index) => {
const globalRowIndex = id * chunkSize + index;

if (globalRowIndex < startRow || globalRowIndex > endRow) {
return null;
}

return <LoadingTableRow key={value} columns={columns} height={rowHeight} />;
})
.filter(Boolean);
}
}

// Data is loaded, but there are no entities in the chunk
if (!currentData.data?.length) {
return (
<EmptyTableRow columns={columns}>
return [
<EmptyTableRow key="empty" columns={columns}>
{renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')}
</EmptyTableRow>
);
</EmptyTableRow>,
];
}

return currentData.data.map((rowData, index) => (
<TableRow
key={index}
row={rowData as T}
columns={columns}
height={rowHeight}
getRowClassName={getRowClassName}
/>
));
return currentData.data
.map((rowData, index) => {
const globalRowIndex = id * chunkSize + index;

if (globalRowIndex < startRow || globalRowIndex > endRow) {
return null;
}

return (
<TableRow
key={index}
row={rowData as T}
columns={columns}
height={rowHeight}
getRowClassName={getRowClassName}
/>
);
})
.filter(Boolean);
};

return (
<tbody
id={id.toString()}
style={{
height: `${dataLength * rowHeight}px`,
// Default display: table-row-group doesn't work in Safari and breaks the table
// display: block works in Safari, but disconnects thead and tbody cell grids
// Hack to make it work in all cases
display: isActive ? 'table-row-group' : 'block',
}}
>
{renderContent()}
</tbody>
);
return renderContent();
});
54 changes: 33 additions & 21 deletions src/components/PaginatedTable/useScrollBasedChunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface UseScrollBasedChunksProps {
overscanCount?: number;
}

const DEFAULT_OVERSCAN_COUNT = 1;
const DEFAULT_OVERSCAN_COUNT = 15;

export const useScrollBasedChunks = ({
scrollContainerRef,
Expand All @@ -20,15 +20,18 @@ export const useScrollBasedChunks = ({
rowHeight,
chunkSize,
overscanCount = DEFAULT_OVERSCAN_COUNT,
}: UseScrollBasedChunksProps): boolean[] => {
}: UseScrollBasedChunksProps): {
visibleRowRange: {start: number; end: number};
totalItems: number;
} => {
const chunksCount = React.useMemo(
() => Math.ceil(totalItems / chunkSize),
[chunkSize, totalItems],
);

const [startChunk, setStartChunk] = React.useState(0);
const [endChunk, setEndChunk] = React.useState(
Math.min(overscanCount, Math.max(chunksCount - 1, 0)),
const [startRow, setStartRow] = React.useState(0);
const [endRow, setEndRow] = React.useState(
Math.min(overscanCount, Math.max(totalItems - 1, 0)),
);

const calculateVisibleRange = React.useCallback(() => {
Expand All @@ -43,19 +46,30 @@ export const useScrollBasedChunks = ({
const visibleStart = Math.max(containerScroll - tableOffset, 0);
const visibleEnd = visibleStart + container.clientHeight;

const start = Math.max(Math.floor(visibleStart / rowHeight / chunkSize) - overscanCount, 0);
const end = Math.min(
Math.floor(visibleEnd / rowHeight / chunkSize) + overscanCount,
Math.max(chunksCount - 1, 0),
);
return {start, end};
}, [scrollContainerRef, tableRef, rowHeight, chunkSize, overscanCount, chunksCount]);
// Calculate row range first
const rowStart = Math.max(Math.floor(visibleStart / rowHeight) - overscanCount, 0);
const rowEnd = Math.min(Math.floor(visibleEnd / rowHeight) + overscanCount, totalItems - 1);

// Calculate chunk range from row range
const start = Math.max(Math.floor(rowStart / chunkSize), 0);
const end = Math.min(Math.floor(rowEnd / chunkSize), Math.max(chunksCount - 1, 0));

return {start, end, rowStart, rowEnd};
}, [
scrollContainerRef,
tableRef,
rowHeight,
chunkSize,
overscanCount,
chunksCount,
totalItems,
]);

const updateVisibleChunks = React.useCallback(() => {
const newRange = calculateVisibleRange();
if (newRange) {
setStartChunk(newRange.start);
setEndChunk(newRange.end);
setStartRow(newRange.rowStart);
setEndRow(newRange.rowEnd);
}
}, [calculateVisibleRange]);

Expand Down Expand Up @@ -94,11 +108,9 @@ export const useScrollBasedChunks = ({
}, [handleScroll, scrollContainerRef]);

return React.useMemo(() => {
// boolean array that represents active chunks
const activeChunks = Array(chunksCount).fill(false);
for (let i = startChunk; i <= endChunk; i++) {
activeChunks[i] = true;
}
return activeChunks;
}, [chunksCount, startChunk, endChunk]);
return {
visibleRowRange: {start: startRow, end: endRow},
totalItems,
};
}, [startRow, endRow, totalItems]);
};
Loading
Loading