From 5b9153cb19039ba2e9d55d4856ec9bc8560d6507 Mon Sep 17 00:00:00 2001 From: Rahul Barwal Date: Mon, 17 Feb 2025 14:29:40 +0530 Subject: [PATCH] feat: Implement infra code for infinite scroll implementation. (#39225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Implements infinite scroll functionality for table widget using react-window-infinite-loader. Introduces new components and hooks to manage virtualized table rendering with dynamic loading of rows. Fixes #39082 _or_ Fixes `Issue URL` > [!WARNING] > _If no issue exists, please create an issue first, and check with the maintainers if the issue is valid._ ## Automation /ok-to-test tags="@tag.Table, @tag.Sanity" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: 0c58fcf83dbfd520958c9989ffb607cf57d1fdb1 > Cypress dashboard. > Tags: `@tag.Table, @tag.Sanity` > Spec: >
Fri, 14 Feb 2025 13:18:37 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## Summary by CodeRabbit - **New Features** - Added infinite scrolling support to table views, enabling seamless data loading as you scroll. - Enhanced table interfaces with improved loading indicators and smoother virtualized rendering for large datasets. - **Chores** - Updated supporting libraries to underpin the improved scrolling and data handling capabilities. --------- --- app/client/package.json | 2 + .../TableWidgetV2/component/StaticTable.tsx | 4 + .../widgets/TableWidgetV2/component/Table.tsx | 4 + .../TableBody/InifiniteScrollBody/index.tsx | 59 +++++++ .../useInfiniteVirtualization.test.tsx | 152 ++++++++++++++++++ .../useInfiniteVirtualization.tsx | 37 +++++ .../component/TableBody/VirtualList.tsx | 91 +++++++++++ .../component/TableBody/index.tsx | 59 +++---- .../TableWidgetV2/component/VirtualTable.tsx | 4 + app/client/yarn.lock | 30 +++- 10 files changed, 398 insertions(+), 44 deletions(-) create mode 100644 app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/index.tsx create mode 100644 app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.test.tsx create mode 100644 app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.tsx create mode 100644 app/client/src/widgets/TableWidgetV2/component/TableBody/VirtualList.tsx diff --git a/app/client/package.json b/app/client/package.json index 43f757a30842..cdaa3998f2b2 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -91,6 +91,7 @@ "@types/d3-geo": "^3.1.0", "@types/google.maps": "^3.51.0", "@types/react-page-visibility": "^6.4.1", + "@types/react-window-infinite-loader": "^1.0.9", "@types/web": "^0.0.99", "@uppy/core": "^1.16.0", "@uppy/dashboard": "^1.16.0", @@ -203,6 +204,7 @@ "react-virtuoso": "^4.5.0", "react-webcam": "^7.0.1", "react-window": "^1.8.6", + "react-window-infinite-loader": "^1.0.10", "react-zoom-pan-pinch": "^1.6.1", "redux": "^4.0.1", "redux-form": "^8.2.6", diff --git a/app/client/src/widgets/TableWidgetV2/component/StaticTable.tsx b/app/client/src/widgets/TableWidgetV2/component/StaticTable.tsx index 6af8dc468d6e..a759641e6218 100644 --- a/app/client/src/widgets/TableWidgetV2/component/StaticTable.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/StaticTable.tsx @@ -42,6 +42,8 @@ type StaticTableProps = TableColumnHeaderProps & { scrollContainerStyles: any; useVirtual: boolean; tableBodyRef?: React.MutableRefObject; + isLoading: boolean; + loadMoreFromEvaluations: () => void; }; const StaticTable = (props: StaticTableProps, ref: React.Ref) => { @@ -81,6 +83,8 @@ const StaticTable = (props: StaticTableProps, ref: React.Ref) => { getTableBodyProps={props.getTableBodyProps} height={props.height} isAddRowInProgress={props.isAddRowInProgress} + isLoading={props.isLoading} + loadMoreFromEvaluations={props.loadMoreFromEvaluations} multiRowSelection={!!props.multiRowSelection} pageSize={props.pageSize} prepareRow={props.prepareRow} diff --git a/app/client/src/widgets/TableWidgetV2/component/Table.tsx b/app/client/src/widgets/TableWidgetV2/component/Table.tsx index 47354361b45b..5045d277d58f 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Table.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/Table.tsx @@ -473,8 +473,10 @@ export function Table(props: TableProps) { headerGroups={headerGroups} height={props.height} isAddRowInProgress={props.isAddRowInProgress} + isLoading={props.isLoading} isResizingColumn={isResizingColumn} isSortable={props.isSortable} + loadMoreFromEvaluations={props.nextPageClick} multiRowSelection={props?.multiRowSelection} pageSize={props.pageSize} prepareRow={prepareRow} @@ -512,8 +514,10 @@ export function Table(props: TableProps) { height={props.height} isAddRowInProgress={props.isAddRowInProgress} isInfiniteScrollEnabled={props.isInfiniteScrollEnabled} + isLoading={props.isLoading} isResizingColumn={isResizingColumn} isSortable={props.isSortable} + loadMoreFromEvaluations={props.nextPageClick} multiRowSelection={props?.multiRowSelection} pageSize={props.pageSize} prepareRow={prepareRow} diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/index.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/index.tsx new file mode 100644 index 000000000000..f2cb83e1cba0 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/index.tsx @@ -0,0 +1,59 @@ +import React, { type Ref } from "react"; +import type { Row as ReactTableRowType } from "react-table"; +import { type ReactElementType } from "react-window"; +import InfiniteLoader from "react-window-infinite-loader"; +import type SimpleBar from "simplebar-react"; +import type { TableSizes } from "../../Constants"; +import { useInfiniteVirtualization } from "./useInfiniteVirtualization"; +import { FixedInfiniteVirtualList } from "../VirtualList"; + +interface InfiniteScrollBodyProps { + rows: ReactTableRowType>[]; + height: number; + tableSizes: TableSizes; + innerElementType?: ReactElementType; + isLoading: boolean; + totalRecordsCount?: number; + itemCount: number; + loadMoreFromEvaluations: () => void; + pageSize: number; +} + +const InfiniteScrollBody = React.forwardRef( + (props: InfiniteScrollBodyProps, ref: Ref) => { + const { isLoading, loadMoreFromEvaluations, pageSize, rows } = props; + const { isItemLoaded, itemCount, loadMoreItems } = + useInfiniteVirtualization({ + rows, + totalRecordsCount: rows.length, + isLoading, + loadMore: loadMoreFromEvaluations, + pageSize, + }); + + return ( +
+ + {({ onItemsRendered, ref: infiniteLoaderRef }) => ( + + )} + +
+ ); + }, +); + +export default InfiniteScrollBody; diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.test.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.test.tsx new file mode 100644 index 000000000000..946a240874b1 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.test.tsx @@ -0,0 +1,152 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useInfiniteVirtualization } from "./useInfiniteVirtualization"; +import { act } from "@testing-library/react"; +import type { Row as ReactTableRowType } from "react-table"; + +describe("useInfiniteVirtualization", () => { + const mockRows: ReactTableRowType>[] = [ + { + id: "1", + original: { id: 1, name: "Test 1" }, + index: 0, + cells: [], + values: {}, + getRowProps: jest.fn(), + allCells: [], + subRows: [], + isExpanded: false, + canExpand: false, + depth: 0, + toggleRowExpanded: jest.fn(), + state: {}, + toggleRowSelected: jest.fn(), + getToggleRowExpandedProps: jest.fn(), + isSelected: false, + isSomeSelected: false, + isGrouped: false, + groupByID: "", + groupByVal: "", + leafRows: [], + getToggleRowSelectedProps: jest.fn(), + setState: jest.fn(), + }, + { + id: "2", + original: { id: 2, name: "Test 2" }, + index: 1, + cells: [], + values: {}, + getRowProps: jest.fn(), + allCells: [], + subRows: [], + isExpanded: false, + canExpand: false, + depth: 0, + toggleRowExpanded: jest.fn(), + state: {}, + toggleRowSelected: jest.fn(), + getToggleRowExpandedProps: jest.fn(), + isSelected: false, + isSomeSelected: false, + isGrouped: false, + groupByID: "", + groupByVal: "", + leafRows: [], + getToggleRowSelectedProps: jest.fn(), + setState: jest.fn(), + }, + ]; + + const defaultProps = { + rows: mockRows, + isLoading: false, + loadMore: jest.fn(), + pageSize: 10, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return correct itemCount when totalRecordsCount is provided", () => { + const totalRecordsCount = 100; + const { result } = renderHook(() => + useInfiniteVirtualization({ + ...defaultProps, + totalRecordsCount, + }), + ); + + expect(result.current.itemCount).toBe(totalRecordsCount); + }); + + it("should return rows length as itemCount when totalRecordsCount is not provided", () => { + const { result } = renderHook(() => + useInfiniteVirtualization(defaultProps), + ); + + expect(result.current.itemCount).toBe(defaultProps.rows.length); + }); + + it("should call loadMore when loadMoreItems is called and not loading", async () => { + const { result } = renderHook(() => + useInfiniteVirtualization(defaultProps), + ); + + await act(async () => { + await result.current.loadMoreItems(0, 10); + }); + + expect(defaultProps.loadMore).toHaveBeenCalledTimes(1); + }); + + it("should not call loadMore when loadMoreItems is called and is loading", async () => { + const { result } = renderHook(() => + useInfiniteVirtualization({ + ...defaultProps, + isLoading: true, + }), + ); + + await act(async () => { + await result.current.loadMoreItems(0, 10); + }); + + expect(defaultProps.loadMore).not.toHaveBeenCalled(); + }); + + it("should return correct isItemLoaded state for different scenarios", () => { + const { result } = renderHook(() => + useInfiniteVirtualization(defaultProps), + ); + + // Index within rows length and not loading + expect(result.current.isItemLoaded(1)).toBe(true); + + // Index beyond rows length and not loading + expect(result.current.isItemLoaded(5)).toBe(false); + }); + + it("should return false for isItemLoaded when loading", () => { + const { result } = renderHook(() => + useInfiniteVirtualization({ + ...defaultProps, + isLoading: true, + }), + ); + + // Even for index within rows length, should return false when loading + expect(result.current.isItemLoaded(1)).toBe(false); + }); + + it("should return zero itemCount when there are no records", () => { + const { result } = renderHook(() => + useInfiniteVirtualization({ + ...defaultProps, + rows: [], + }), + ); + + expect(result.current.itemCount).toBe(0); + }); +}); diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.tsx new file mode 100644 index 000000000000..e43540af5fa9 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.tsx @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import type { Row as ReactTableRowType } from "react-table"; + +interface InfiniteVirtualizationProps { + rows: ReactTableRowType>[]; + totalRecordsCount?: number; + isLoading: boolean; + loadMore: () => void; + pageSize: number; +} + +interface UseInfiniteVirtualizationReturn { + itemCount: number; + loadMoreItems: (startIndex: number, stopIndex: number) => void; + isItemLoaded: (index: number) => boolean; +} + +export const useInfiniteVirtualization = ({ + isLoading, + loadMore, + rows, + totalRecordsCount, +}: InfiniteVirtualizationProps): UseInfiniteVirtualizationReturn => { + const loadMoreItems = useCallback(async () => { + if (!isLoading) { + loadMore(); + } + + return Promise.resolve(); + }, [isLoading, loadMore]); + + return { + itemCount: totalRecordsCount ?? rows.length, + loadMoreItems, + isItemLoaded: (index) => !isLoading && index < rows.length, + }; +}; diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/VirtualList.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/VirtualList.tsx new file mode 100644 index 000000000000..c4d79bc97d5c --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/VirtualList.tsx @@ -0,0 +1,91 @@ +import type { ListOnItemsRenderedProps, ReactElementType } from "react-window"; +import { FixedSizeList, areEqual } from "react-window"; +import React from "react"; +import type { ListChildComponentProps } from "react-window"; +import type { Row as ReactTableRowType } from "react-table"; +import { WIDGET_PADDING } from "constants/WidgetConstants"; +import { EmptyRow, Row } from "./Row"; +import type { TableSizes } from "../Constants"; +import type SimpleBar from "simplebar-react"; + +const rowRenderer = React.memo((rowProps: ListChildComponentProps) => { + const { data, index, style } = rowProps; + + if (index < data.length) { + const row = data[index]; + + return ( + + ); + } else { + return ; + } +}, areEqual); + +interface BaseVirtualListProps { + height: number; + tableSizes: TableSizes; + rows: ReactTableRowType>[]; + pageSize: number; + innerElementType?: ReactElementType; + outerRef?: React.Ref; + onItemsRendered?: (props: ListOnItemsRenderedProps) => void; + infiniteLoaderListRef?: React.Ref; +} + +const BaseVirtualList = React.memo(function BaseVirtualList({ + height, + infiniteLoaderListRef, + innerElementType, + onItemsRendered, + outerRef, + pageSize, + rows, + tableSizes, +}: BaseVirtualListProps) { + return ( + + {rowRenderer} + + ); +}); + +/** + * The difference between next two components is in the number of arguments they expect. + */ +export const FixedInfiniteVirtualList = React.memo( + function FixedInfiniteVirtualList(props: BaseVirtualListProps) { + return ; + }, +); + +type FixedVirtualListProps = Omit< + BaseVirtualListProps, + "onItemsRendered" | "infiniteLoaderListRef" +>; +export const FixedVirtualList = React.memo(function FixedVirtualList( + props: FixedVirtualListProps, +) { + return ; +}); diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx index dd23a4155cf6..3a5f4b63a209 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx @@ -5,13 +5,13 @@ import type { TableBodyPropGetter, TableBodyProps, } from "react-table"; -import type { ListChildComponentProps, ReactElementType } from "react-window"; -import { FixedSizeList, areEqual } from "react-window"; -import { WIDGET_PADDING } from "constants/WidgetConstants"; -import { EmptyRows, EmptyRow, Row } from "./Row"; +import { type ReactElementType } from "react-window"; +import type SimpleBar from "simplebar-react"; import type { ReactTableColumnProps, TableSizes } from "../Constants"; import type { HeaderComponentProps } from "../Table"; -import type SimpleBar from "simplebar-react"; +import InfiniteScrollBody from "./InifiniteScrollBody"; +import { EmptyRows, Row } from "./Row"; +import { FixedVirtualList } from "./VirtualList"; export type BodyContextType = { accentColor: string; @@ -49,26 +49,6 @@ export const BodyContext = React.createContext({ totalColumnsWidth: 0, }); -const rowRenderer = React.memo((rowProps: ListChildComponentProps) => { - const { data, index, style } = rowProps; - - if (index < data.length) { - const row = data[index]; - - return ( - - ); - } else { - return ; - } -}, areEqual); - interface BodyPropsType { getTableBodyProps( propGetter?: TableBodyPropGetter> | undefined, @@ -80,28 +60,22 @@ interface BodyPropsType { tableSizes: TableSizes; innerElementType?: ReactElementType; isInfiniteScrollEnabled?: boolean; + isLoading: boolean; + loadMoreFromEvaluations: () => void; } const TableVirtualBodyComponent = React.forwardRef( (props: BodyPropsType, ref: Ref) => { return (
- - {rowRenderer} - + pageSize={props.pageSize} + rows={props.rows} + tableSizes={props.tableSizes} + />
); }, @@ -191,7 +165,12 @@ export const TableBody = React.forwardRef( }} > {isInfiniteScrollEnabled ? ( -
Infinite Scroll
+ ) : useVirtual ? ( void; }; const VirtualTable = (props: VirtualTableProps, ref: React.Ref) => { @@ -61,8 +63,10 @@ const VirtualTable = (props: VirtualTableProps, ref: React.Ref) => { innerElementType={VirtualTableInnerElement} isAddRowInProgress={props.isAddRowInProgress} isInfiniteScrollEnabled={props.isInfiniteScrollEnabled} + isLoading={props.isLoading} isResizingColumn={props.isResizingColumn} isSortable={props.isSortable} + loadMoreFromEvaluations={props.loadMoreFromEvaluations} multiRowSelection={!!props.multiRowSelection} pageSize={props.pageSize} prepareRow={props.prepareRow} diff --git a/app/client/yarn.lock b/app/client/yarn.lock index fb2dcfec865f..cd0289c01363 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -11192,12 +11192,22 @@ __metadata: languageName: node linkType: hard -"@types/react-window@npm:^1.8.2": - version: 1.8.2 - resolution: "@types/react-window@npm:1.8.2" +"@types/react-window-infinite-loader@npm:^1.0.9": + version: 1.0.9 + resolution: "@types/react-window-infinite-loader@npm:1.0.9" dependencies: "@types/react": "*" - checksum: c127ed420d881510fe647539342e7c494802aab12fd6cb61f9f8ba47ef16d3683e632b7a6a07eb0d284ea8f0953ae7941eafa2c51c0bcb3b176d009eac09c79a + "@types/react-window": "*" + checksum: 9f2c27f24bfa726ceaef6612a4adbda745f3455c877193f68dfa48591274c670a6df4fa6870785cff5f948e289ceb9a247fb7cbf67e3cd555ab16d11866fd63f + languageName: node + linkType: hard + +"@types/react-window@npm:*, @types/react-window@npm:^1.8.2": + version: 1.8.8 + resolution: "@types/react-window@npm:1.8.8" + dependencies: + "@types/react": "*" + checksum: 253c9d6e0c942f34633edbddcbc369324403c42458ff004457c5bd5972961d8433a909c0cc1a89c918063d5eb85ecbdd774142af2555fae61f4ceb3ba9884b5a languageName: node linkType: hard @@ -13003,6 +13013,7 @@ __metadata: "@types/react-tabs": ^2.3.1 "@types/react-test-renderer": ^17.0.1 "@types/react-window": ^1.8.2 + "@types/react-window-infinite-loader": ^1.0.9 "@types/redux-form": ^8.1.9 "@types/redux-mock-store": ^1.0.2 "@types/shallowequal": ^1.1.5 @@ -13206,6 +13217,7 @@ __metadata: react-virtuoso: ^4.5.0 react-webcam: ^7.0.1 react-window: ^1.8.6 + react-window-infinite-loader: ^1.0.10 react-zoom-pan-pinch: ^1.6.1 redux: ^4.0.1 redux-devtools-extension: ^2.13.8 @@ -29019,6 +29031,16 @@ __metadata: languageName: node linkType: hard +"react-window-infinite-loader@npm:^1.0.10": + version: 1.0.10 + resolution: "react-window-infinite-loader@npm:1.0.10" + peerDependencies: + react: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 3ee79ce325e45a7d4d9f92c13e7ff4c523578fa454de3a440980b286d964eb951095c012a7f43ca75e9d86ed2b052c81b08134dfa8827144f44b059cc56514c3 + languageName: node + linkType: hard + "react-window@npm:^1.8.6": version: 1.8.8 resolution: "react-window@npm:1.8.8"