Skip to content

Commit

Permalink
feat: Implement infra code for infinite scroll implementation. (#39225)
Browse files Browse the repository at this point in the history
## 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"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13329193341>
> Commit: 0c58fcf
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13329193341&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Table, @tag.Sanity`
> Spec:
> <hr>Fri, 14 Feb 2025 13:18:37 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------
  • Loading branch information
rahulbarwal authored Feb 17, 2025
1 parent 22688f9 commit 5b9153c
Show file tree
Hide file tree
Showing 10 changed files with 398 additions and 44 deletions.
2 changes: 2 additions & 0 deletions app/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ type StaticTableProps = TableColumnHeaderProps & {
scrollContainerStyles: any;
useVirtual: boolean;
tableBodyRef?: React.MutableRefObject<HTMLDivElement | null>;
isLoading: boolean;
loadMoreFromEvaluations: () => void;
};

const StaticTable = (props: StaticTableProps, ref: React.Ref<SimpleBar>) => {
Expand Down Expand Up @@ -81,6 +83,8 @@ const StaticTable = (props: StaticTableProps, ref: React.Ref<SimpleBar>) => {
getTableBodyProps={props.getTableBodyProps}
height={props.height}
isAddRowInProgress={props.isAddRowInProgress}
isLoading={props.isLoading}
loadMoreFromEvaluations={props.loadMoreFromEvaluations}
multiRowSelection={!!props.multiRowSelection}
pageSize={props.pageSize}
prepareRow={props.prepareRow}
Expand Down
4 changes: 4 additions & 0 deletions app/client/src/widgets/TableWidgetV2/component/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>[];
height: number;
tableSizes: TableSizes;
innerElementType?: ReactElementType;
isLoading: boolean;
totalRecordsCount?: number;
itemCount: number;
loadMoreFromEvaluations: () => void;
pageSize: number;
}

const InfiniteScrollBody = React.forwardRef(
(props: InfiniteScrollBodyProps, ref: Ref<SimpleBar>) => {
const { isLoading, loadMoreFromEvaluations, pageSize, rows } = props;
const { isItemLoaded, itemCount, loadMoreItems } =
useInfiniteVirtualization({
rows,
totalRecordsCount: rows.length,
isLoading,
loadMore: loadMoreFromEvaluations,
pageSize,
});

return (
<div className="simplebar-content-wrapper">
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={itemCount + 5}
loadMoreItems={loadMoreItems}
>
{({ onItemsRendered, ref: infiniteLoaderRef }) => (
<FixedInfiniteVirtualList
height={props.height}
infiniteLoaderListRef={infiniteLoaderRef}
innerElementType={props.innerElementType}
onItemsRendered={onItemsRendered}
outerRef={ref}
pageSize={props.pageSize}
rows={props.rows}
tableSizes={props.tableSizes}
/>
)}
</InfiniteLoader>
</div>
);
},
);

export default InfiniteScrollBody;
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>[] = [
{
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);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback } from "react";
import type { Row as ReactTableRowType } from "react-table";

interface InfiniteVirtualizationProps {
rows: ReactTableRowType<Record<string, unknown>>[];
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,
};
};
Original file line number Diff line number Diff line change
@@ -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 (
<Row
className="t--virtual-row"
index={index}
key={index}
row={row}
style={style}
/>
);
} else {
return <EmptyRow style={style} />;
}
}, areEqual);

interface BaseVirtualListProps {
height: number;
tableSizes: TableSizes;
rows: ReactTableRowType<Record<string, unknown>>[];
pageSize: number;
innerElementType?: ReactElementType;
outerRef?: React.Ref<SimpleBar>;
onItemsRendered?: (props: ListOnItemsRenderedProps) => void;
infiniteLoaderListRef?: React.Ref<FixedSizeList>;
}

const BaseVirtualList = React.memo(function BaseVirtualList({
height,
infiniteLoaderListRef,
innerElementType,
onItemsRendered,
outerRef,
pageSize,
rows,
tableSizes,
}: BaseVirtualListProps) {
return (
<FixedSizeList
className="virtual-list simplebar-content"
height={
height -
tableSizes.TABLE_HEADER_HEIGHT -
2 * tableSizes.VERTICAL_PADDING
}
innerElementType={innerElementType}
itemCount={Math.max(rows.length, pageSize)}
itemData={rows}
itemSize={tableSizes.ROW_HEIGHT}
onItemsRendered={onItemsRendered}
outerRef={outerRef}
ref={infiniteLoaderListRef}
width={`calc(100% + ${2 * WIDGET_PADDING}px)`}
>
{rowRenderer}
</FixedSizeList>
);
});

/**
* The difference between next two components is in the number of arguments they expect.
*/
export const FixedInfiniteVirtualList = React.memo(
function FixedInfiniteVirtualList(props: BaseVirtualListProps) {
return <BaseVirtualList {...props} />;
},
);

type FixedVirtualListProps = Omit<
BaseVirtualListProps,
"onItemsRendered" | "infiniteLoaderListRef"
>;
export const FixedVirtualList = React.memo(function FixedVirtualList(
props: FixedVirtualListProps,
) {
return <BaseVirtualList {...props} />;
});
Loading

0 comments on commit 5b9153c

Please sign in to comment.