diff --git a/src/components/PaginatedTable/PaginatedTable.tsx b/src/components/PaginatedTable/PaginatedTable.tsx index 07e5c325a..72fd89959 100644 --- a/src/components/PaginatedTable/PaginatedTable.tsx +++ b/src/components/PaginatedTable/PaginatedTable.tsx @@ -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'; @@ -15,6 +14,7 @@ import type { RenderErrorMessage, } from './types'; import {useScrollBasedChunks} from './useScrollBasedChunks'; +import {useVirtualizedTbodies} from './useVirtualizedTbodies'; import './PaginatedTable.scss'; @@ -36,7 +36,7 @@ export interface PaginatedTableProps { keepCache?: boolean; } -const DEFAULT_PAGINATION_LIMIT = 20; +const DEFAULT_PAGINATION_LIMIT = 50; export const PaginatedTable = ({ limit: chunkSize = DEFAULT_PAGINATION_LIMIT, @@ -63,7 +63,7 @@ export const PaginatedTable = ({ const tableRef = React.useRef(null); - const activeChunks = useScrollBasedChunks({ + const {visibleRowRange, totalItems} = useScrollBasedChunks({ scrollContainerRef, tableRef, totalItems: foundEntities, @@ -78,15 +78,6 @@ export const PaginatedTable = ({ 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) => { if (data) { @@ -99,6 +90,17 @@ export const PaginatedTable = ({ [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; @@ -109,33 +111,29 @@ export const PaginatedTable = ({ setIsInitialLoad(true); }, [initialEntitiesCount, setTotalEntities, setFoundEntities, setIsInitialLoad]); - const renderChunks = () => { - return activeChunks.map((isActive, index) => ( - - 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 = () => ( - {renderChunks()} +
+ {renderChunks()} +
); diff --git a/src/components/PaginatedTable/TableChunk.tsx b/src/components/PaginatedTable/TableChunk.tsx index 0a4085a7e..29870d280 100644 --- a/src/components/PaginatedTable/TableChunk.tsx +++ b/src/components/PaginatedTable/TableChunk.tsx @@ -29,8 +29,9 @@ interface TableChunkProps { columns: Column[]; filters?: F; sortParams?: SortParams; - isActive: boolean; tableName: string; + startRow: number; + endRow: number; fetchData: FetchData; getRowClassName?: GetRowClassName; @@ -56,8 +57,9 @@ export const TableChunk = typedMemo(function TableChunk({ renderErrorMessage, renderEmptyDataMessage, onDataFetched, - isActive, keepCache, + startRow, + endRow, }: TableChunkProps) { const [isTimeoutActive, setIsTimeoutActive] = React.useState(true); const [autoRefreshInterval] = useAutoRefreshInterval(); @@ -75,7 +77,7 @@ export const TableChunk = typedMemo(function TableChunk({ }; tableDataApi.useFetchTableChunkQuery(queryParams, { - skip: isTimeoutActive || !isActive, + skip: isTimeoutActive, pollingInterval: autoRefreshInterval, refetchOnMountOrArgChange: !keepCache, }); @@ -85,7 +87,7 @@ export const TableChunk = typedMemo(function TableChunk({ React.useEffect(() => { let timeout = 0; - if (isActive && isTimeoutActive) { + if (isTimeoutActive) { timeout = window.setTimeout(() => { setIsTimeoutActive(false); }, DEBOUNCE_TIMEOUT); @@ -94,10 +96,10 @@ export const TableChunk = typedMemo(function TableChunk({ return () => { window.clearTimeout(timeout); }; - }, [isActive, isTimeoutActive]); + }, [isTimeoutActive]); React.useEffect(() => { - if (currentData && isActive) { + if (currentData) { onDataFetched({ ...currentData, data: currentData.data as T[], @@ -105,66 +107,67 @@ export const TableChunk = typedMemo(function TableChunk({ 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 ( - + return [ + {renderErrorMessage ? ( renderErrorMessage(errorData) ) : ( )} - - ); + , + ]; } else { - return getArray(dataLength).map((value) => ( - - )); + return getArray(dataLength) + .map((value, index) => { + const globalRowIndex = id * chunkSize + index; + + if (globalRowIndex < startRow || globalRowIndex > endRow) { + return null; + } + + return ; + }) + .filter(Boolean); } } // Data is loaded, but there are no entities in the chunk if (!currentData.data?.length) { - return ( - + return [ + {renderEmptyDataMessage ? renderEmptyDataMessage() : i18n('empty')} - - ); + , + ]; } - return currentData.data.map((rowData, index) => ( - - )); + return currentData.data + .map((rowData, index) => { + const globalRowIndex = id * chunkSize + index; + + if (globalRowIndex < startRow || globalRowIndex > endRow) { + return null; + } + + return ( + + ); + }) + .filter(Boolean); }; - return ( - - {renderContent()} - - ); + return renderContent(); }); diff --git a/src/components/PaginatedTable/useScrollBasedChunks.ts b/src/components/PaginatedTable/useScrollBasedChunks.ts index b05b5d458..6184f19b9 100644 --- a/src/components/PaginatedTable/useScrollBasedChunks.ts +++ b/src/components/PaginatedTable/useScrollBasedChunks.ts @@ -11,7 +11,7 @@ interface UseScrollBasedChunksProps { overscanCount?: number; } -const DEFAULT_OVERSCAN_COUNT = 1; +const DEFAULT_OVERSCAN_COUNT = 15; export const useScrollBasedChunks = ({ scrollContainerRef, @@ -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(() => { @@ -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]); @@ -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]); }; diff --git a/src/components/PaginatedTable/useVirtualizedTbodies.tsx b/src/components/PaginatedTable/useVirtualizedTbodies.tsx new file mode 100644 index 000000000..0e9b24530 --- /dev/null +++ b/src/components/PaginatedTable/useVirtualizedTbodies.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import {TableChunk} from './TableChunk'; +import type { + Column, + FetchData, + GetRowClassName, + PaginatedTableData, + RenderEmptyDataMessage, + RenderErrorMessage, + SortParams, +} from './types'; + +interface UseVirtualizedTbodiesProps { + visibleRowRange: {start: number; end: number}; + totalItems: number; + chunkSize: number; + rowHeight: number; + columns: Column[]; + fetchData: FetchData; + filters?: F; + tableName: string; + sortParams?: SortParams; + getRowClassName?: GetRowClassName; + renderErrorMessage?: RenderErrorMessage; + renderEmptyDataMessage?: RenderEmptyDataMessage; + onDataFetched: (data?: PaginatedTableData) => void; + keepCache?: boolean; +} + +export const useVirtualizedTbodies = ({ + visibleRowRange, + totalItems, + chunkSize, + rowHeight, + columns, + fetchData, + filters, + tableName, + sortParams, + getRowClassName, + renderErrorMessage, + renderEmptyDataMessage, + onDataFetched, + keepCache = true, +}: UseVirtualizedTbodiesProps) => { + const startRow = visibleRowRange.start; + const endRow = visibleRowRange.end; + + const renderChunks = React.useCallback(() => { + // Calculate which chunks contain visible rows + const totalChunks = Math.ceil(totalItems / chunkSize); + const startChunk = Math.max(0, Math.floor(startRow / chunkSize)); + const endChunk = Math.min(totalChunks - 1, Math.floor(endRow / chunkSize)); + + // Collect active chunks + const activeChunkElements: React.ReactElement[] = []; + + for (let i = startChunk; i <= endChunk; i++) { + const chunkRowCount = i === totalChunks - 1 ? totalItems - i * chunkSize : chunkSize; + + activeChunkElements.push( + + key={i} + id={i} + calculatedCount={chunkRowCount} + chunkSize={chunkSize} + rowHeight={rowHeight} + columns={columns} + fetchData={fetchData} + filters={filters} + tableName={tableName} + sortParams={sortParams} + getRowClassName={getRowClassName} + renderErrorMessage={renderErrorMessage} + renderEmptyDataMessage={renderEmptyDataMessage} + onDataFetched={onDataFetched} + keepCache={keepCache} + startRow={startRow} + endRow={endRow} + />, + ); + } + + const activeChunksTopOffset = startRow * rowHeight; + + return ( + + {activeChunkElements} + + ); + }, [ + startRow, + endRow, + totalItems, + chunkSize, + rowHeight, + columns, + fetchData, + filters, + tableName, + sortParams, + getRowClassName, + renderErrorMessage, + renderEmptyDataMessage, + onDataFetched, + keepCache, + ]); + + return {renderChunks}; +};