-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Implement infra code for infinite scroll implementation. (#39225)
## 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
1 parent
22688f9
commit 5b9153c
Showing
10 changed files
with
398 additions
and
44 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
59 changes: 59 additions & 0 deletions
59
app/client/src/widgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
152 changes: 152 additions & 0 deletions
152
.../TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
37 changes: 37 additions & 0 deletions
37
...dgets/TableWidgetV2/component/TableBody/InifiniteScrollBody/useInfiniteVirtualization.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
}; |
91 changes: 91 additions & 0 deletions
91
app/client/src/widgets/TableWidgetV2/component/TableBody/VirtualList.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} />; | ||
}); |
Oops, something went wrong.