-
Notifications
You must be signed in to change notification settings - Fork 4.6k
feat: implement table widget infinite scroll with dynamic height #39646
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jacquesikot
merged 18 commits into
release
from
feat/implement-infinite-scroll-with-dynamic-height
Mar 20, 2025
Merged
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
3d3060e
feat: Implement dynamic row height measurement for table widget
jacquesikot 034bba2
Merge branch 'release' of https://github.com/appsmithorg/appsmith int…
jacquesikot d645c32
feat: Enhance table widget row height handling with dynamic column me…
jacquesikot 6a50d65
feat: Improve table row height calculation with dynamic data tracking
jacquesikot 808d706
refactor: Simplify VirtualList component by extracting base implement…
jacquesikot f500054
refactor: Extract BodyContext to separate file to resolve import depe…
jacquesikot 90d6218
fix: Initialize row height tracking for static and virtual tables
jacquesikot bdc2a31
fix: Improve type safety in TableV2 infinite scroll row height tests
jacquesikot 5b8a9cc
chore: Remove unnecessary comment in BodyContext file
jacquesikot f32f509
feat: Implement dynamic row height measurement for table widget
jacquesikot 69f64f7
Merge branch 'release' of https://github.com/appsmithorg/appsmith int…
rahulbarwal 922f26a
refactor: implement variable row height support in virtual table
jacquesikot c724e0a
refactor: make row properties optional in TableWidgetV2 for improved …
jacquesikot a80cdae
Merge branch 'release' of https://github.com/appsmithorg/appsmith int…
jacquesikot 8954902
refactor: optimize row rendering in BaseVirtualList with memoization
jacquesikot 748542f
Merge branch 'release' of https://github.com/appsmithorg/appsmith int…
jacquesikot d8eddca
refactor: enhance row height measurement logic in VirtualTable
jacquesikot 9f50f22
fix: Removed initialization of cellIndexesWithHTMLCell to improve cla…
jacquesikot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
163 changes: 163 additions & 0 deletions
163
...ypress/e2e/Regression/ClientSide/Widgets/TableV2/InfiniteScrollVariableHeightRows_spec.ts
This file contains hidden or 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,163 @@ | ||
| import { featureFlagIntercept } from "../../../../../support/Objects/FeatureFlags"; | ||
| import { propPane, table } from "../../../../../support/Objects/ObjectsCore"; | ||
|
|
||
| const DEFAULT_ROW_HEIGHT = 40; | ||
|
|
||
| describe( | ||
| "Table Widget Dynamic Row Height", | ||
| { tags: ["@tag.Widget", "@tag.Table"] }, | ||
| function () { | ||
| before(() => { | ||
| featureFlagIntercept({ | ||
| release_tablev2_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, | ||
| name: "Short text", | ||
| description: "This is a short description", | ||
| }, | ||
| { | ||
| id: 2, | ||
| name: "Medium length text", | ||
| description: | ||
| "This description is a bit longer and might wrap to the next line depending on column width", | ||
| }, | ||
| { | ||
| 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.", | ||
| }, | ||
| ]; | ||
|
|
||
| // Set the table data | ||
| propPane.EnterJSContext("Table data", JSON.stringify(testData)); | ||
|
|
||
| // Turn on Infinite Scroll | ||
| propPane.TogglePropertyState("Infinite scroll", "On"); | ||
| }); | ||
|
|
||
| it("1. Should maintain fixed height when cell wrapping is disabled and no HTML cells are present", () => { | ||
| cy.get(".t--widget-tablewidgetv2 .tbody .tr").each(($row) => { | ||
| cy.wrap($row) | ||
| .invoke("outerHeight") | ||
| .then((height) => { | ||
| expect(Math.ceil(height!)).to.equal(DEFAULT_ROW_HEIGHT); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it("2. Should increase row height when cell wrapping is enabled", () => { | ||
| // turn on cell wrapping | ||
| 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") | ||
| .then((height) => { | ||
| expect(Math.ceil(height!)).to.be.greaterThan(DEFAULT_ROW_HEIGHT); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| 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) => { | ||
| currentRowHeight = Math.ceil(height!); | ||
| }); | ||
| }); | ||
|
|
||
| // updated table data with extermely long text | ||
| const updatedTestData = [ | ||
| { | ||
| id: 4, | ||
| name: "Short text", | ||
| description: "This is a short description", | ||
| }, | ||
| { | ||
| id: 5, | ||
| 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! > maxHeight) { | ||
| maxHeight = height!; | ||
| } | ||
| }); | ||
| }) | ||
| .then(() => { | ||
| expect(maxHeight).to.be.greaterThan(currentRowHeight); | ||
| }); | ||
| }); | ||
|
|
||
| it("4. Should revert to fixed height when cell wrapping is disabled", () => { | ||
| // turn off cell wrapping | ||
| table.EditColumn("description", "v2"); | ||
| 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") | ||
| .then((height) => { | ||
| expect(Math.ceil(height!)).to.equal(DEFAULT_ROW_HEIGHT); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| it("5. Should handle HTML content in cells with proper height adjustment", () => { | ||
| // Create test data with HTML content | ||
| const htmlTestData = [ | ||
| { | ||
| id: 6, | ||
| 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(); | ||
|
|
||
| // get the height of the row with the longest text | ||
| cy.get(".t--widget-tablewidgetv2 .tbody .tr").each(($row) => { | ||
| cy.wrap($row) | ||
| .invoke("outerHeight") | ||
| .then((height) => { | ||
| expect(Math.ceil(height!)).to.be.greaterThan(DEFAULT_ROW_HEIGHT); | ||
| }); | ||
| }); | ||
| }); | ||
| }, | ||
| ); |
This file contains hidden or 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
115 changes: 115 additions & 0 deletions
115
app/client/src/widgets/TableWidgetV2/component/TableBody/BaseVirtualList.tsx
This file contains hidden or 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,115 @@ | ||
| import { WIDGET_PADDING } from "constants/WidgetConstants"; | ||
| import React, { useCallback, useContext } from "react"; | ||
| import type { Row as ReactTableRowType } from "react-table"; | ||
| import type { | ||
| ListChildComponentProps, | ||
| ListOnItemsRenderedProps, | ||
| ReactElementType, | ||
| } from "react-window"; | ||
| import { VariableSizeList, areEqual } from "react-window"; | ||
| import type SimpleBar from "simplebar-react"; | ||
| import { BodyContext } from "./BodyContext"; | ||
| import type { TableSizes } from "../Constants"; | ||
| import { EmptyRow, Row } from "./Row"; | ||
|
|
||
| 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); | ||
|
|
||
| export interface BaseVirtualListProps { | ||
| height: number; | ||
| tableSizes: TableSizes; | ||
| rows: ReactTableRowType<Record<string, unknown>>[]; | ||
| innerElementType?: ReactElementType; | ||
| outerRef?: React.Ref<SimpleBar>; | ||
| onItemsRendered?: (props: ListOnItemsRenderedProps) => void; | ||
| infiniteLoaderListRef?: React.Ref<VariableSizeList>; | ||
| itemCount: number; | ||
| pageSize: number; | ||
| } | ||
|
|
||
| const BaseVirtualList = React.memo(function BaseVirtualList({ | ||
| height, | ||
| infiniteLoaderListRef, | ||
| innerElementType, | ||
| itemCount, | ||
| onItemsRendered, | ||
| outerRef, | ||
| rows, | ||
| tableSizes, | ||
| }: BaseVirtualListProps) { | ||
| const { listRef, rowHeights } = useContext(BodyContext); | ||
|
|
||
| const combinedRef = (list: VariableSizeList | null) => { | ||
| // Handle infiniteLoaderListRef | ||
| if (infiniteLoaderListRef) { | ||
| if (typeof infiniteLoaderListRef === "function") { | ||
| infiniteLoaderListRef(list); | ||
| } else { | ||
| ( | ||
| infiniteLoaderListRef as React.MutableRefObject<VariableSizeList | null> | ||
| ).current = list; | ||
| } | ||
| } | ||
|
|
||
| // Handle listRef - only if it's a mutable ref | ||
| if (listRef && "current" in listRef) { | ||
| (listRef as React.MutableRefObject<VariableSizeList | null>).current = | ||
| list; | ||
| } | ||
| }; | ||
|
|
||
| const getItemSize = useCallback( | ||
| (index: number) => { | ||
| try { | ||
| // Add a minimum height threshold to prevent rows from being too small | ||
| const rowHeight = rowHeights.current?.[index] || tableSizes.ROW_HEIGHT; | ||
|
|
||
| return Math.max(rowHeight, tableSizes.ROW_HEIGHT); | ||
| } catch (error) { | ||
| return tableSizes.ROW_HEIGHT; | ||
| } | ||
| }, | ||
| [rowHeights.current, tableSizes.ROW_HEIGHT], | ||
| ); | ||
|
|
||
| return ( | ||
| <VariableSizeList | ||
| className="virtual-list simplebar-content" | ||
| estimatedItemSize={tableSizes.ROW_HEIGHT} | ||
| height={ | ||
| height - | ||
| tableSizes.TABLE_HEADER_HEIGHT - | ||
| 2 * tableSizes.VERTICAL_PADDING | ||
| } | ||
| innerElementType={innerElementType} | ||
| itemCount={itemCount} | ||
| itemData={rows} | ||
| itemSize={getItemSize} | ||
| onItemsRendered={onItemsRendered} | ||
| outerRef={outerRef} | ||
| ref={combinedRef} | ||
| width={`calc(100% + ${2 * WIDGET_PADDING}px)`} | ||
| > | ||
| {rowRenderer} | ||
| </VariableSizeList> | ||
| ); | ||
| }); | ||
|
|
||
| export default BaseVirtualList; |
52 changes: 52 additions & 0 deletions
52
app/client/src/widgets/TableWidgetV2/component/TableBody/BodyContext.tsx
This file contains hidden or 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,52 @@ | ||
| // Create a new file for the context | ||
| import React, { type RefObject } from "react"; | ||
| import type { | ||
| Row as ReactTableRowType, | ||
| TableBodyPropGetter, | ||
| TableBodyProps, | ||
| } from "react-table"; | ||
| import type { VariableSizeList } from "react-window"; | ||
| import type { ReactTableColumnProps } from "../Constants"; | ||
| import type { HeaderComponentProps } from "../Table"; | ||
|
|
||
| export type BodyContextType = { | ||
|
jacquesikot marked this conversation as resolved.
Outdated
|
||
| accentColor: string; | ||
| borderRadius: string; | ||
| multiRowSelection: boolean; | ||
| prepareRow?(row: ReactTableRowType<Record<string, unknown>>): void; | ||
| selectTableRow?: (row: { | ||
| original: Record<string, unknown>; | ||
| index: number; | ||
| }) => void; | ||
| selectedRowIndex: number; | ||
| selectedRowIndices: number[]; | ||
| columns: ReactTableColumnProps[]; | ||
| width: number; | ||
| rows: ReactTableRowType<Record<string, unknown>>[]; | ||
| primaryColumnId?: string; | ||
| isAddRowInProgress: boolean; | ||
| getTableBodyProps?( | ||
| propGetter?: TableBodyPropGetter<Record<string, unknown>> | undefined, | ||
| ): TableBodyProps; | ||
| totalColumnsWidth?: number; | ||
| rowHeights: RefObject<{ [key: number]: number }>; | ||
| rowNeedsMeasurement: RefObject<{ [key: number]: boolean }>; | ||
| listRef: RefObject<VariableSizeList> | null; | ||
| } & Partial<HeaderComponentProps>; | ||
|
|
||
| export const BodyContext = React.createContext<BodyContextType>({ | ||
| accentColor: "", | ||
| borderRadius: "", | ||
| multiRowSelection: false, | ||
| selectedRowIndex: -1, | ||
| selectedRowIndices: [], | ||
| columns: [], | ||
| width: 0, | ||
| rows: [], | ||
| primaryColumnId: "", | ||
| isAddRowInProgress: false, | ||
| totalColumnsWidth: 0, | ||
| rowHeights: { current: {} }, | ||
| rowNeedsMeasurement: { current: {} }, | ||
| listRef: null, | ||
| }); | ||
This file contains hidden or 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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.