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:
parent
961cbd28bf
commit
5a6479c5dd
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user