feat: reset table when infinite scroll is turned on (#40066)

## 🐞 Problem

We've identified several issues with the TableWidgetV2's infinite scroll
functionality:

- **Stale Data After Toggle**  
When users enable infinite scroll for the first time, the table's state
and cached data aren't properly reset, potentially leading to incorrect
data display and inconsistent behavior.

- **Height Changes Breaking Existing Data**  
When a table's height is increased while infinite scroll is enabled, the
existing cached data becomes invalid due to changing offsets, but the
table wasn't resetting its state accordingly.

- **Empty Initial View**  
When loading a table with infinite scroll enabled, rows were not visible
during the initial load until data was fetched, creating a jarring user
experience.

- **Disabled Properties Still Rendering Controls**  
Property controls that should be disabled (based on section disabling
conditions) were still being rendered and active, causing unexpected
behavior.

---

##  Solution

### 1. Implement Table Reset on Infinite Scroll Toggle

Added a new method `resetTableForInfiniteScroll()` that properly resets
the table's state when infinite scroll is enabled. This method:

- Clears cached table data  
- Resets the "end of data" flag  
- Resets all meta properties to their default values  
- Sets the page number back to `1` and triggers a page load

```ts
resetTableForInfiniteScroll = () => {
  const { infiniteScrollEnabled, pushBatchMetaUpdates } = this.props;

  if (infiniteScrollEnabled) {
    // reset the cachedRows
    pushBatchMetaUpdates("cachedTableData", {});
    pushBatchMetaUpdates("endOfData", false);

    // reset the meta properties
    const metaProperties = Object.keys(TableWidgetV2.getMetaPropertiesMap());
    metaProperties.forEach((prop) => {
      if (prop !== "pageNo") {
        const defaultValue = TableWidgetV2.getMetaPropertiesMap()[prop];
        this.props.updateWidgetMetaProperty(prop, defaultValue);
      }
    });

    // reset and reload page
    this.updatePageNumber(1, EventType.ON_NEXT_PAGE);
  }
};
```

---

### 2. Reset on Height Changes

Added a check in `componentDidUpdate` to detect height changes and reset
the table when needed:

```ts
// Reset widget state when height changes while infinite scroll is enabled
if (
  infiniteScrollEnabled &&
  prevProps.componentHeight !== componentHeight
) {
  this.resetTableForInfiniteScroll();
}
```

---

### 3. Improved Empty State Rendering

Modified the `InfiniteScrollBodyComponent` to show placeholder rows
during initial load by using the maximum of `rows.length` and
`pageSize`:

```ts
const itemCount = useMemo(
  () => Math.max(rows.length, pageSize),
  [rows.length, pageSize],
);
```

This ensures the table maintains its expected height and appearance even
before data is loaded.

---

### 4. Fixed Property Control Rendering

Fixed the `PropertyControl` component to respect the `isControlDisabled`
flag by conditionally rendering the control:

```ts
{!isControlDisabled &&
  PropertyControlFactory.createControl(
    config,
    {
      onPropertyChange: onPropertyChange,
      // ...other props
    },
    // ...other args
  )}
```

This prevents disabled controls from being rendered and potentially
causing issues.

---

These improvements significantly enhance the stability and user
experience of **TableWidgetV2**'s infinite scroll functionality.


Fixes #39377 

## Automation

/ok-to-test tags="@tag.Table, @tag.Widget, @tag.Binding, @tag.Sanity,
@tag.PropertyPane"

### 🔍 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/14373134089>
> Commit: 2b0715bbbe2e9a254cd287f831329be529a17c3c
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=14373134089&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Table, @tag.Widget, @tag.Binding, @tag.Sanity,
@tag.PropertyPane`
> Spec:
> <hr>Thu, 10 Apr 2025 07:15:53 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**
- Property panels now display controls only when enabled, enhancing
clarity.
- Table widgets offer smoother infinite scrolling with automatic resets
on state or size changes.
- Columns dynamically adjust for optimal display when infinite scrolling
is active.
- **Bug Fixes**
- Improved handling of item counts and loading states in infinite
scrolling.
- **Refactor**
- Improved performance through optimized item computations and
streamlined scrolling logic.
	- Removed redundant loading button logic for a cleaner user experience.
- **Tests**
- Expanded test scenarios to verify improved content wrapping and rich
HTML rendering in table cells, with a focus on internal logic and
behavior.
- Enhanced clarity and robustness of infinite scroll tests by verifying
loading through scrolling actions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Rahul Barwal <rahul.barwal@appsmith.com>
This commit is contained in:
Jacques Ikot 2025-04-10 03:58:15 -07:00 committed by GitHub
parent 961cbd28bf
commit 5a6479c5dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 338 additions and 203 deletions

View File

@ -12,23 +12,37 @@ describe(
release_table_infinitescroll_enabled: true,
});
// Set up a table with test data
cy.dragAndDropToCanvas("tablewidgetv2", { x: 300, y: 300 });
// Create test data with varying content lengths
const testData = [
{
id: 1,
id: 1.5,
name: "Very long text content",
description:
"This is a very long description that will definitely wrap to multiple lines when cell wrapping is enabled. It contains enough text to ensure that the row height will need to expand significantly to accommodate all the content properly.",
},
{
id: 2,
name: "Very long text content",
description:
"This is a very long description that will definitely wrap to multiple lines when cell wrapping is enabled. It contains enough text to ensure that the row height will need to expand significantly to accommodate all the content properly.",
},
{
id: 3,
name: "Very long text content",
description:
"This is a very long description that will definitely wrap to multiple lines when cell wrapping is enabled. It contains enough text to ensure that the row height will need to expand significantly to accommodate all the content properly.",
},
{
id: 10,
name: "HTML content",
description:
"<div>This is a <strong>formatted</strong> description with <br/><br/>multiple line breaks<br/>and formatting</div>",
},
];
// Set the table data
propPane.EnterJSContext("Table data", JSON.stringify(testData));
// Turn on Infinite Scroll
propPane.TogglePropertyState("Infinite scroll", "On");
});
@ -44,13 +58,10 @@ describe(
});
});
it("2. Should increase row height when cell wrapping is enabled", () => {
// turn on cell wrapping
it("2. should change height when cell wrapping is turned on", () => {
table.EditColumn("description", "v2");
propPane.TogglePropertyState("Cell wrapping", "On");
propPane.NavigateBackToPropertyPane();
// get the height of the row with the longest text
cy.get(".t--widget-tablewidgetv2 .tbody .tr").each(($row) => {
cy.wrap($row)
.invoke("outerHeight")
@ -62,61 +73,9 @@ describe(
});
});
it("3. Should update row heights when content changes", () => {
// check and store current row height in variable
let currentRowHeight = 0;
cy.get(".t--widget-tablewidgetv2 .tbody .tr").each(($row) => {
cy.wrap($row)
.invoke("outerHeight")
.then((height) => {
if (height !== undefined) {
currentRowHeight = Math.ceil(height);
}
});
});
// updated table data with extermely long text
const updatedTestData = [
{
id: 2,
name: "Short text",
description: "This is a short description",
},
{
id: 3,
name: "Extremely long text",
description:
"This is an extremely long description that will definitely wrap to multiple lines when cell wrapping is enabled. It contains enough text to ensure that the row height will need to expand significantly to accommodate all the content properly. We're adding even more text here to make sure the row expands further than before. The height measurement should reflect this change in content length appropriately. Additionally, this text continues with more detailed information about how the wrapping behavior works in practice. When dealing with variable height rows, it's important to validate that the table can handle content of any length gracefully. This extra text helps us verify that the row height calculations are working correctly even with very long content that spans multiple lines. The table should automatically adjust the row height to fit all of this content while maintaining proper scrolling and layout behavior. We want to ensure there are no visual glitches or truncation issues when displaying such lengthy content.",
},
];
// update the table data
propPane.EnterJSContext("Table data", JSON.stringify(updatedTestData));
// Find the tallest row in the table
let maxHeight = 0;
cy.get(".t--widget-tablewidgetv2 .tbody .tr")
.each(($row, index) => {
cy.wrap($row)
.invoke("outerHeight")
.then((height) => {
if (height !== undefined && height > maxHeight) {
maxHeight = height;
}
});
})
.then(() => {
expect(maxHeight).to.be.at.least(currentRowHeight);
});
});
it("4. Should revert to fixed height when cell wrapping is disabled", () => {
// turn off cell wrapping
table.EditColumn("description", "v2");
it("3. should change height when cell wrapping is turned off", () => {
propPane.TogglePropertyState("Cell wrapping", "Off");
propPane.NavigateBackToPropertyPane();
// get the height of the row with the longest text
cy.get(".t--widget-tablewidgetv2 .tbody .tr").each(($row) => {
cy.wrap($row)
.invoke("outerHeight")
@ -126,26 +85,12 @@ describe(
}
});
});
propPane.NavigateBackToPropertyPane();
});
it("5. Should handle HTML content in cells with proper height adjustment", () => {
// Create test data with HTML content
const htmlTestData = [
{
id: 4,
name: "HTML content",
description:
"<div>This is a <strong>formatted</strong> description with <br/><br/>multiple line breaks<br/>and formatting</div>",
},
];
// Update the table data
propPane.EnterJSContext("Table data", JSON.stringify(htmlTestData));
// update the column type to html
table.EditColumn("description", "v2");
propPane.SelectPropertiesDropDown("Column type", "HTML");
propPane.NavigateBackToPropertyPane();
it("4. Should handle HTML content in cells with proper height adjustment", () => {
table.ChangeColumnType("description", "HTML");
// Find the tallest row in the table
let maxHeight = 0;

View File

@ -1,23 +1,21 @@
import OneClickBindingLocator from "../../../../../locators/OneClickBindingLocator";
import { featureFlagIntercept } from "../../../../../support/Objects/FeatureFlags";
import {
agHelper,
assertHelper,
dataSources,
deployMode,
entityExplorer,
locators,
propPane,
table,
deployMode,
dataSources,
locators,
assertHelper,
} from "../../../../../support/Objects/ObjectsCore";
import EditorNavigation, {
AppSidebar,
AppSidebarButton,
EntityType,
} from "../../../../../support/Pages/EditorNavigation";
import { OneClickBinding } from "../../OneClickBinding/spec_utility";
import {
AppSidebar,
AppSidebarButton,
} from "../../../../../support/Pages/EditorNavigation";
import OneClickBindingLocator from "../../../../../locators/OneClickBindingLocator";
const oneClickBinding = new OneClickBinding();
@ -48,12 +46,12 @@ describe(
agHelper.AssertClassExists(locators._jsToggle("tabledata"), "is-active");
});
it("1. should enable infinite scroll and verify records are loaded and loaded more records works", () => {
it("1. should enable infinite scroll and verify records are loaded automatically when scrolling", () => {
// Enable infinite scroll in the property pane
propPane.TogglePropertyState("Infinite scroll", "On");
// Verify that server-side pagination is automatically enabled
propPane.TogglePropertyState("Server side pagination", "On");
// Wait for network call to complete
assertHelper.AssertNetworkStatus("@postExecute", 200);
// Verify initial rows are visible
table.ReadTableRowColumnData(0, 1, "v2").then(($cellData) => {
@ -64,9 +62,38 @@ describe(
expect($cellData).to.not.be.empty;
});
table.infiniteScrollLoadMoreRecords();
// Store the current number of rows
let initialRowCount = 0;
cy.get(".t--widget-tablewidgetv2 .tbody .tr").then(($rows) => {
initialRowCount = $rows.length;
// Verify that the next page is loaded
// Scroll vertically to trigger infinite scroll
cy.get(".t--widget-tablewidgetv2 .virtual-list").scrollTo(0, 1000, {
duration: 500,
});
// Wait for network call to complete after scrolling
assertHelper.AssertNetworkStatus("@postExecute", 200);
// Use waitUntil to wait for the condition that more rows are loaded
cy.waitUntil(
() =>
cy
.get(".t--widget-tablewidgetv2 .tbody .tr")
.then(($newRows) => $newRows.length > initialRowCount),
{
errorMsg: "New rows were not loaded after scrolling",
timeout: 10000,
interval: 500,
},
);
// Verify more rows were loaded
cy.get(".t--widget-tablewidgetv2 .tbody .tr").then(($newRows) => {
const newRowCount = $newRows.length;
expect(newRowCount).to.be.greaterThan(initialRowCount);
});
});
});
it("2. should test row selection with infinite scroll", () => {
@ -144,8 +171,22 @@ describe(
expect($cellData).to.not.be.empty;
});
table.infiniteScrollLoadMoreRecords();
// Verify that the next page is loaded
// Store the current number of rows
let initialRowCount = 0;
cy.get(".t--widget-tablewidgetv2 .tbody .tr").then(($rows) => {
initialRowCount = $rows.length;
// Scroll to the bottom of the table to trigger automatic loading of more records
cy.get(".t--widget-tablewidgetv2 .virtual-list").scrollTo("bottom");
// Wait for network call to complete
assertHelper.AssertNetworkStatus("@postExecute", 200);
// Verify more rows were loaded
cy.get(".t--widget-tablewidgetv2 .tbody .tr").then(($newRows) => {
expect($newRows.length).to.be.greaterThan(initialRowCount);
});
});
cy.get(".t--widget-tablewidgetv2 .virtual-list").scrollTo("topLeft");

View File

@ -863,23 +863,6 @@ export class Table {
return `.t--widget-tablewidgetv2 .tbody .td[data-rowindex=${rowNum}][data-colindex=${colNum}]`;
}
public infiniteScrollLoadMoreRecords() {
// Scroll to the bottom of the virtual list to ensure the load more button is visible
cy.get(".t--widget-tablewidgetv2 .virtual-list")
.scrollTo("bottomLeft")
.then(() => {
// Wait for the load more button to be visible after scrolling
this.agHelper
.ScrollIntoView(this._loadMoreButton)
.should("be.visible")
.then(() => {
// Click the load more button once it's visible
this.agHelper.GetNClick(this._loadMoreButton, 0, true);
});
});
this.assertHelper.AssertNetworkStatus("@postExecute", 200);
}
public FreezeColumn(direction: "left" | "right" | "" = "") {
this.agHelper.GetNClick(this._freezeColumn(direction), 0, true);
}

View File

@ -1128,23 +1128,24 @@ const PropertyControl = memo((props: Props) => {
</div>
</div>
)}
{PropertyControlFactory.createControl(
config,
{
onPropertyChange: onPropertyChange,
onBatchUpdateProperties: onBatchUpdateProperties,
openNextPanel: openPanel,
deleteProperties: onDeleteProperties,
onBatchUpdateWithAssociatedUpdates:
onBatchUpdateWithAssociatedWidgetUpdates,
theme: props.theme,
},
isDynamic,
customJSControl,
additionAutocomplete,
hideEvaluatedValue(),
props.isSearchResult,
)}
{!isControlDisabled &&
PropertyControlFactory.createControl(
config,
{
onPropertyChange: onPropertyChange,
onBatchUpdateProperties: onBatchUpdateProperties,
openNextPanel: openPanel,
deleteProperties: onDeleteProperties,
onBatchUpdateWithAssociatedUpdates:
onBatchUpdateWithAssociatedWidgetUpdates,
theme: props.theme,
},
isDynamic,
customJSControl,
additionAutocomplete,
hideEvaluatedValue(),
props.isSearchResult,
)}
<PropertyPaneHelperText helperText={helperText} />
</ControlWrapper>
);

View File

@ -1,5 +1,5 @@
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { VariableInfiniteVirtualList } from "./VirtualList";
import type { Row as ReactTableRowType } from "react-table";
import "@testing-library/jest-dom";
@ -131,16 +131,19 @@ describe("VirtualList", () => {
expect(screen.getAllByRole("row")).toHaveLength(3);
});
it("2. Should render Load More button when hasMoreData is true", () => {
it("2. Should handle infinite scrolling with onItemsRendered callback", () => {
const mockRows = createMockRows(3);
const loadMoreMock = jest.fn();
const onItemsRenderedMock = jest.fn();
render(
<VariableInfiniteVirtualList
hasMoreData
height={500}
infiniteLoaderListRef={{ current: null }}
itemCount={mockRows.length}
loadMore={loadMoreMock}
onItemsRendered={onItemsRenderedMock}
outerRef={{ current: null }}
pageSize={10}
rows={mockRows}
@ -148,24 +151,56 @@ describe("VirtualList", () => {
/>,
);
const loadMoreButton = screen.getByRole("button", {
name: "Load more records",
// Verify onItemsRendered was called with the correct parameters
expect(onItemsRenderedMock).toHaveBeenCalledWith({
overscanStartIndex: 0,
overscanStopIndex: 2,
visibleStartIndex: 0,
visibleStopIndex: 2,
});
expect(loadMoreButton).toBeInTheDocument();
expect(screen.getByText("Load More")).toBeInTheDocument();
});
it("3. Should not render Load More button when hasMoreData is false", () => {
it("3. Should correctly set itemCount when hasMoreData is true", () => {
const mockRows = createMockRows(3);
const loadMoreMock = jest.fn();
const mockVariableSizeList =
jest.requireMock("react-window").VariableSizeList;
const spy = jest.spyOn(mockVariableSizeList, "render");
render(
<VariableInfiniteVirtualList
hasMoreData
height={500}
itemCount={mockRows.length}
outerRef={{ current: null }}
pageSize={10}
rows={mockRows}
tableSizes={mockTableSizes}
/>,
);
// This test verifies that itemCount is increased by 1 when hasMoreData is true
// The BaseVirtualList component adds LOAD_MORE_BUTTON_ROW (1) to itemCount
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
itemCount: mockRows.length,
}),
expect.anything(),
);
spy.mockRestore();
});
it("4. Should not increase itemCount when hasMoreData is false", () => {
const mockRows = createMockRows(3);
const mockVariableSizeList =
jest.requireMock("react-window").VariableSizeList;
const spy = jest.spyOn(mockVariableSizeList, "render");
render(
<VariableInfiniteVirtualList
hasMoreData={false}
height={500}
itemCount={mockRows.length}
loadMore={loadMoreMock}
outerRef={{ current: null }}
pageSize={10}
rows={mockRows}
@ -173,13 +208,18 @@ describe("VirtualList", () => {
/>,
);
expect(
screen.queryByRole("button", { name: "Load more records" }),
).not.toBeInTheDocument();
expect(screen.queryByText("Load More")).not.toBeInTheDocument();
// When hasMoreData is false, itemCount should not be increased
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
itemCount: mockRows.length,
}),
expect.anything(),
);
spy.mockRestore();
});
it("4. Should call loadMore when Load More button is clicked", () => {
it("5. Should pass loadMore callback to inner components", () => {
const mockRows = createMockRows(3);
const loadMoreMock = jest.fn();
@ -196,36 +236,8 @@ describe("VirtualList", () => {
/>,
);
const loadMoreButton = screen.getByRole("button", {
name: "Load more records",
});
fireEvent.click(loadMoreButton);
expect(loadMoreMock).toHaveBeenCalledTimes(1);
});
it("5. Should treat row data and load more data properly when both are provided", () => {
const mockRows = createMockRows(3);
const loadMoreMock = jest.fn();
render(
<VariableInfiniteVirtualList
hasMoreData
height={500}
itemCount={mockRows.length}
loadMore={loadMoreMock}
outerRef={{ current: null }}
pageSize={10}
rows={mockRows}
tableSizes={mockTableSizes}
/>,
);
// Should have regular rows
// We can't directly test if the loadMore function is passed to the MemoizedRow,
// but we can verify the component renders properly with the loadMore prop
expect(screen.getAllByRole("row")).toHaveLength(3);
// And the Load More button
expect(screen.getByText("Load More")).toBeInTheDocument();
});
});

View File

@ -11,7 +11,6 @@ import type SimpleBar from "simplebar-react";
import type { TableSizes } from "../Constants";
import { Row } from "../TableBodyCoreComponents/Row";
import { EmptyRows } from "../cellComponents/EmptyCell";
import LoadMoreButton from "../LoadMoreButton";
type ExtendedListChildComponentProps = ListChildComponentProps & {
listRef: React.RefObject<VariableSizeList>;
@ -24,16 +23,8 @@ type ExtendedListChildComponentProps = ListChildComponentProps & {
// Create a memoized row component using areEqual from react-window
export const MemoizedRow = React.memo(
(rowProps: ExtendedListChildComponentProps) => {
const {
data,
hasMoreData,
index,
listRef,
loadMore,
rowHeights,
rowNeedsMeasurement,
style,
} = rowProps;
const { data, index, listRef, rowHeights, rowNeedsMeasurement, style } =
rowProps;
if (index < data.length) {
const row = data[index];
@ -50,8 +41,6 @@ export const MemoizedRow = React.memo(
style={style}
/>
);
} else if (index === data.length && hasMoreData) {
return <LoadMoreButton loadMore={loadMore} style={style} />;
} else {
return <EmptyRows rows={1} style={style} />;
}
@ -85,8 +74,6 @@ export interface BaseVirtualListProps {
hasMoreData?: boolean;
}
const LOAD_MORE_BUTTON_ROW = 1;
const BaseVirtualList = React.memo(function BaseVirtualList({
hasMoreData,
height,
@ -159,7 +146,7 @@ const BaseVirtualList = React.memo(function BaseVirtualList({
2 * tableSizes.VERTICAL_PADDING
}
innerElementType={innerElementType}
itemCount={hasMoreData ? itemCount + LOAD_MORE_BUTTON_ROW : itemCount}
itemCount={itemCount}
itemData={rows}
itemSize={getItemSize}
onItemsRendered={onItemsRendered}

View File

@ -1,4 +1,4 @@
import React, { type Ref } from "react";
import React, { useMemo, type Ref } from "react";
import { type ReactElementType } from "react-window";
import type SimpleBar from "simplebar-react";
import { LoadingIndicator } from "../../LoadingIndicator";
@ -22,20 +22,28 @@ const InfiniteScrollBodyComponent = React.forwardRef(
tableSizes,
} = useAppsmithTable();
useInfiniteScroll({
const { onItemsRendered } = useInfiniteScroll({
rows,
pageSize,
loadMore: nextPageClick,
isLoading,
endOfData,
});
const itemCount = useMemo(
() => Math.max(rows.length, pageSize),
[rows.length, pageSize],
);
return (
<div className="simplebar-content-wrapper">
<VariableInfiniteVirtualList
hasMoreData={!endOfData}
height={height}
innerElementType={props.innerElementType}
itemCount={rows.length}
itemCount={itemCount}
loadMore={nextPageClick}
onItemsRendered={onItemsRendered}
outerRef={ref}
pageSize={pageSize}
rows={rows}

View File

@ -1,23 +1,113 @@
import { useEffect, useRef, useCallback } from "react";
import { debounce } from "lodash";
import type { Row } from "react-table";
import { useEffect } from "react";
import type { ListOnItemsRenderedProps } from "react-window";
export interface UseInfiniteScrollProps {
loadMore: () => void;
rows: Row<Record<string, unknown>>[];
pageSize: number;
isLoading: boolean;
endOfData: boolean;
}
export interface UseInfiniteScrollReturn {
onItemsRendered: (props: ListOnItemsRenderedProps) => void;
}
export const useInfiniteScroll = ({
endOfData,
isLoading,
loadMore,
pageSize,
rows,
}: UseInfiniteScrollProps) => {
}: UseInfiniteScrollProps): UseInfiniteScrollReturn => {
const lastLoadedPageRef = useRef(1);
const haveWeJustTriggeredLoadMoreRef = useRef(false);
const hasLoadedSecondPageRef = useRef(false);
const lastRenderedRowInCurrentViewPortRef = useRef(0);
const currentPage = Math.ceil(rows.length / pageSize);
/**
* We implement debouncing to avoid triggering unnecessary load more events, incorporating an additional timeout of 100 milliseconds to further prevent this.
* There is also a ref that indicates whether a load more request has just been triggered, serving as a safety net to prevent multiple simultaneous requests.
*/
const debouncedLoadMore = useCallback(
debounce(() => {
if (!isLoading && !endOfData && !haveWeJustTriggeredLoadMoreRef.current) {
haveWeJustTriggeredLoadMoreRef.current = true;
loadMore();
setTimeout(() => {
haveWeJustTriggeredLoadMoreRef.current = false;
}, 100);
}
}, 150),
[isLoading, endOfData, loadMore],
);
/**
* This is the point where the loading functionality is activated.
* Essentially, upon rendering items, the system identifies the last rendered position and calculates which page is currently visible.
* this method identifies the last row in the visible React window that has been rendered.
* Based on the row index, we can determine which page we are currently on.
* If this page happens to be the last one, we will trigger a load more request.
* For instance, if you have loaded 50 rows and are viewing a subset of 20 to 30 rows, it will determine the page number to be 2.
* Since this is not the last page, it will refrain from triggering another load request.
* However, if you scroll from item 40 to 41, the system detects that item 41 is now in view and
* recalculates the page to reflect the last loaded page number.
* This will trigger a load more request.
*
* This approach is efficient, as it prevents unnecessary load requests when the rendered view range changes, especially if the user is scrolling in the opposite direction.
*/
const onItemsRendered = useCallback(
(props: ListOnItemsRenderedProps) => {
const { visibleStopIndex } = props;
const currentVisiblePage = Math.ceil(visibleStopIndex / pageSize);
const isInLastPage = currentVisiblePage === currentPage;
if (
isInLastPage &&
!isLoading &&
!endOfData &&
visibleStopIndex > lastRenderedRowInCurrentViewPortRef.current
) {
lastRenderedRowInCurrentViewPortRef.current = visibleStopIndex;
debouncedLoadMore();
}
},
[currentPage, isLoading, endOfData, pageSize, debouncedLoadMore],
);
/**
* when the user scrolls infinitely, loading only one set of data would not facilitate proper scrolling on the page.
* To enable smooth scrolling, we need to load at least two sets of data.
* Thus, the effect checks whether the second page has been loaded; if not, it triggers a load more request immediately after the first set of data has been retrieved.
*/
useEffect(() => {
// If cachedRows is just a single page, call loadMore to fetch the next page
if (rows.length > 0 && rows.length <= pageSize) {
loadMore();
if (rows.length > 0) {
const newPage = Math.ceil(rows.length / pageSize);
if (rows.length <= pageSize && !hasLoadedSecondPageRef.current) {
hasLoadedSecondPageRef.current = true;
loadMore();
}
if (newPage > lastLoadedPageRef.current) {
lastLoadedPageRef.current = newPage;
}
}
}, [rows.length, pageSize, loadMore]);
return;
// Cleanup debounced function
useEffect(() => {
return () => {
debouncedLoadMore.cancel();
};
}, [debouncedLoadMore]);
return {
onItemsRendered,
};
};

View File

@ -120,7 +120,6 @@ function ReactTableComponent(props: ReactTableComponentProps) {
borderColor,
borderWidth,
canFreezeColumn,
columns,
columnWidthMap,
compactMode,
delimiter,
@ -173,6 +172,16 @@ function ReactTableComponent(props: ReactTableComponentProps) {
width,
} = props;
let columns = props.columns;
if (isInfiniteScrollEnabled) {
const regularColumns = columns.filter(
(col) => col.columnProperties?.columnType !== ColumnTypes.EDIT_ACTIONS,
);
columns = [...regularColumns];
}
const sortTableColumn = useCallback(
(columnIndex: number, asc: boolean) => {
if (allowSorting) {

View File

@ -921,11 +921,15 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
componentDidUpdate(prevProps: TableWidgetProps) {
const {
commitBatchMetaUpdates,
componentHeight,
defaultSelectedRowIndex,
defaultSelectedRowIndices,
infiniteScrollEnabled,
pageNo,
pageSize,
primaryColumns = {},
pushBatchMetaUpdates,
serverSidePaginationEnabled,
totalRecordsCount,
} = this.props;
@ -959,8 +963,6 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
// Check if tableData is modifed
const isTableDataModified = this.props.tableData !== prevProps.tableData;
const { commitBatchMetaUpdates, pushBatchMetaUpdates } = this.props;
// If the user has changed the tableData OR
// The binding has returned a new value
if (isTableDataModified) {
@ -1040,6 +1042,20 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
}
}
// Reset widget state when infinite scroll is initially enabled
// This should come after all updateInfiniteScrollProperties are done
if (!prevProps.infiniteScrollEnabled && infiniteScrollEnabled) {
this.resetTableForInfiniteScroll();
}
// Reset widget state when height changes while infinite scroll is enabled
if (
infiniteScrollEnabled &&
prevProps.componentHeight !== componentHeight
) {
this.resetTableForInfiniteScroll();
}
/*
* When defaultSelectedRowIndex or defaultSelectedRowIndices
* is changed from property pane
@ -3046,6 +3062,49 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
}
}
}
resetTableForInfiniteScroll = () => {
const {
infiniteScrollEnabled,
pushBatchMetaUpdates,
updateWidgetMetaProperty,
} = this.props;
if (infiniteScrollEnabled) {
// reset the cachedRows
const isAlreadyOnFirstPage = this.props.pageNo === 1;
const data = isAlreadyOnFirstPage ? { 1: this.props.tableData } : {};
pushBatchMetaUpdates("cachedTableData", data);
pushBatchMetaUpdates("endOfData", false);
// Explicitly reset specific meta properties
updateWidgetMetaProperty("selectedRowIndex", undefined);
updateWidgetMetaProperty("selectedRowIndices", undefined);
updateWidgetMetaProperty("searchText", undefined);
updateWidgetMetaProperty("triggeredRowIndex", undefined);
updateWidgetMetaProperty("filters", []);
updateWidgetMetaProperty("sortOrder", {
column: "",
order: null,
});
updateWidgetMetaProperty("transientTableData", {});
updateWidgetMetaProperty("updatedRowIndex", -1);
updateWidgetMetaProperty("editableCell", defaultEditableCell);
updateWidgetMetaProperty("columnEditableCellValue", {});
updateWidgetMetaProperty("selectColumnFilterText", {});
updateWidgetMetaProperty("isAddRowInProgress", false);
updateWidgetMetaProperty("newRowContent", undefined);
updateWidgetMetaProperty("newRow", undefined);
updateWidgetMetaProperty("previousPageVisited", false);
updateWidgetMetaProperty("nextPageVisited", false);
// reset and reload page
if (!isAlreadyOnFirstPage) {
this.updatePageNumber(1, EventType.ON_NEXT_PAGE);
}
}
};
}
export default TableWidgetV2;