diff --git a/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js new file mode 100644 index 0000000000..fc73794cc2 --- /dev/null +++ b/app/client/cypress/integration/Smoke_TestSuite/ClientSideTests/Widgets/TableV2/virtual_row_spec.js @@ -0,0 +1,73 @@ +import { ObjectsRegistry } from "../../../../../support/Objects/Registry"; + +const PropertyPane = ObjectsRegistry.PropertyPane; +const totalRows = 100; + +describe("Table Widget Virtualized Row", function() { + before(() => { + cy.dragAndDropToCanvas("tablewidgetv2", { x: 300, y: 600 }); + const row = { + step: "#3", + task: "Bind the query using => fetch_users.data", + status: "--", + action: "", + }; + + const rows = new Array(totalRows).fill("").map((d, i) => ({ + ...row, + step: i, + })); + + PropertyPane.UpdatePropertyFieldValue("Table Data", JSON.stringify(rows)); + PropertyPane.ToggleOnOrOff("Server Side Pagination", "On"); + PropertyPane.ToggleOnOrOff("Show Pagination", "Off"); + }); + + it("1. should check that row is getting rendered", () => { + cy.get(".tr[data-rowindex]").should("exist"); + cy.get(".td[data-rowindex]").should("exist"); + }); + + it("2. should check that virtual rows are getting rendered when scrolling through the table", () => { + cy.get(".tr[data-rowindex]").should("not.have.length", totalRows); + cy.get(".tr[data-rowindex='0']").should("exist"); + cy.get(".tbody > div").scrollTo("bottom"); + cy.wait(500); + cy.get(".tr[data-rowindex='0']").should("not.exist"); + cy.get(".tr[data-rowindex='98']").should("exist"); + cy.get(".tbody > div").scrollTo("top"); + cy.wait(500); + cy.get(".tr[data-rowindex='0']").should("exist"); + cy.get(".tr[data-rowindex='98']").should("not.exist"); + cy.get(".t--virtual-row").should("exist"); + }); + + it("3. should check that virtual rows feature is turned off when cell wrapping is enabled", () => { + cy.editColumn("step"); + cy.wait(500); + PropertyPane.ToggleOnOrOff("Cell Wrapping", "On"); + cy.get(".tr[data-rowindex]").should("have.length", totalRows); + cy.get(".tr[data-rowindex='0']").should("exist"); + cy.get(".tr[data-rowindex='98']").should("exist"); + cy.get(".tbody").scrollTo("bottom"); + cy.wait(500); + cy.get(".tr[data-rowindex='0']").should("exist"); + cy.get(".tr[data-rowindex='98']").should("exist"); + cy.get(".tbody").scrollTo("top"); + cy.wait(500); + cy.get(".tr[data-rowindex='0']").should("exist"); + cy.get(".tr[data-rowindex='98']").should("exist"); + cy.get(".t--virtual-row").should("not.exist"); + }); + + it("4. should check that virtual rows feature is turned off when server side pagination is disabled", () => { + PropertyPane.ToggleOnOrOff("Cell Wrapping", "Off"); + PropertyPane.NavigateBackToPropertyPane(); + cy.wait(500); + PropertyPane.ToggleOnOrOff("Show Pagination", "On"); + cy.wait(500); + PropertyPane.ToggleOnOrOff("Server Side Pagination", "Off"); + cy.get(".tr[data-rowindex]").should("have.length", 5); + cy.get(".t--virtual-row").should("not.exist"); + }); +}); diff --git a/app/client/package.json b/app/client/package.json index dca1b189e8..4bbde68a7f 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -53,6 +53,7 @@ "exceljs-lightweight": "^1.14.0", "fast-deep-equal": "^3.1.3", "fast-xml-parser": "^3.17.5", + "fastdom": "^1.0.11", "flow-bin": "^0.148.0", "focus-trap-react": "^8.9.2", "fuse.js": "^3.4.5", diff --git a/app/client/src/widgets/TableWidgetV2/component/Constants.ts b/app/client/src/widgets/TableWidgetV2/component/Constants.ts index ca20e82861..77165fdc83 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Constants.ts +++ b/app/client/src/widgets/TableWidgetV2/component/Constants.ts @@ -464,3 +464,5 @@ export const scrollbarOnHoverCSS = ` } } `; + +export const MULTISELECT_CHECKBOX_WIDTH = 40; diff --git a/app/client/src/widgets/TableWidgetV2/component/Table.tsx b/app/client/src/widgets/TableWidgetV2/component/Table.tsx index cc820c6777..345582fecb 100644 --- a/app/client/src/widgets/TableWidgetV2/component/Table.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/Table.tsx @@ -6,7 +6,7 @@ import { useBlockLayout, useResizeColumns, useRowSelect, - Row, + Row as ReactTableRowType, } from "react-table"; import { TableWrapper, @@ -28,12 +28,10 @@ import { ScrollIndicator } from "design-system"; import { EventType } from "constants/AppsmithActionConstants/ActionConstants"; import { Scrollbars } from "react-custom-scrollbars"; import { renderEmptyRows } from "./cellComponents/EmptyCell"; -import { - renderBodyCheckBoxCell, - renderHeaderCheckBoxCell, -} from "./cellComponents/SelectionCheckboxCell"; +import { renderHeaderCheckBoxCell } from "./cellComponents/SelectionCheckboxCell"; import { HeaderCell } from "./cellComponents/HeaderCell"; import { EditableCell } from "../constants"; +import { TableBody } from "./TableBody"; interface TableProps { width: number; @@ -68,7 +66,7 @@ interface TableProps { enableDrag: () => void; toggleAllRowSelect: ( isSelect: boolean, - pageData: Row>[], + pageData: ReactTableRowType>[], ) => void; triggerRowSelection: boolean; searchTableData: (searchKey: any) => void; @@ -85,6 +83,7 @@ interface TableProps { boxShadow?: string; onBulkEditDiscard: () => void; onBulkEditSave: () => void; + primaryColumnId?: string; } const defaultColumn = { @@ -185,7 +184,6 @@ export function Table(props: TableProps) { endIndex = props.data.length; } const subPage = page.slice(startIndex, endIndex); - const selectedRowIndex = props.selectedRowIndex; const selectedRowIndices = props.selectedRowIndices || []; const tableSizes = TABLE_SIZES[props.compactMode || CompactModeTypes.DEFAULT]; const tableWrapperRef = useRef(null); @@ -228,6 +226,12 @@ export function Table(props: TableProps) { [props.width], ); + const shouldUseVirtual = + props.serverSidePaginationEnabled && + !props.columns.some( + (column) => !!column.columnProperties.allowCellWrapping, + ); + return ( -
subPage.length ? "no-scroll" : "" - }`} + - {subPage.map((row, rowIndex) => { - prepareRow(row); - const rowProps = { - ...row.getRowProps(), - style: { display: "flex" }, - }; - const isRowSelected = props.multiRowSelection - ? selectedRowIndices.includes(row.index) - : row.index === selectedRowIndex; - return ( -
{ - row.toggleRowSelected(); - props.selectTableRow(row); - e.stopPropagation(); - }} - > - {props.multiRowSelection && - renderBodyCheckBoxCell( - isRowSelected, - props.accentColor, - props.borderRadius, - )} - {row.cells.map((cell, cellIndex) => { - return ( -
- {cell.render("Cell")} -
- ); - })} -
- ); - })} - {props.pageSize > subPage.length && - renderEmptyRows( - props.pageSize - subPage.length, - props.columns, - props.width, - subPage, - prepareRow, - props.multiRowSelection, - props.accentColor, - props.borderRadius, - )} -
+ rows={subPage} + selectTableRow={props.selectTableRow} + selectedRowIndex={props.selectedRowIndex} + selectedRowIndices={props.selectedRowIndices} + tableSizes={tableSizes} + useVirtual={shouldUseVirtual} + width={props.width} + /> diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx new file mode 100644 index 0000000000..2abf69de54 --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/Row.tsx @@ -0,0 +1,129 @@ +import React, { CSSProperties, Key, useContext } from "react"; +import { Row as ReactTableRowType } from "react-table"; +import { ListChildComponentProps } from "react-window"; +import { BodyContext } from "."; +import { renderEmptyRows } from "../cellComponents/EmptyCell"; +import { renderBodyCheckBoxCell } from "../cellComponents/SelectionCheckboxCell"; + +type RowType = { + className?: string; + index: number; + row: ReactTableRowType>; + style?: ListChildComponentProps["style"]; +}; + +export function Row(props: RowType) { + const { + accentColor, + borderRadius, + multiRowSelection, + prepareRow, + primaryColumnId, + selectedRowIndex, + selectedRowIndices, + selectTableRow, + } = useContext(BodyContext); + + prepareRow?.(props.row); + const rowProps = { + ...props.row.getRowProps(), + style: { + display: "flex", + ...(props.style || {}), + }, + }; + const isRowSelected = multiRowSelection + ? selectedRowIndices.includes(props.row.index) + : props.row.index === selectedRowIndex; + + const key = + (primaryColumnId && (props.row.original[primaryColumnId] as Key)) || + props.index; + + return ( +
{ + props.row.toggleRowSelected(); + selectTableRow?.(props.row); + e.stopPropagation(); + }} + role="button" + > + {multiRowSelection && + renderBodyCheckBoxCell(isRowSelected, accentColor, borderRadius)} + {props.row.cells.map((cell, cellIndex) => { + return ( +
+ {cell.render("Cell")} +
+ ); + })} +
+ ); +} + +export const EmptyRows = (props: { + style?: CSSProperties; + rowCount: number; +}) => { + const { + accentColor, + borderRadius, + columns, + multiRowSelection, + prepareRow, + rows, + width, + } = useContext(BodyContext); + + return ( + <> + {renderEmptyRows( + props.rowCount, + columns, + width, + rows, + multiRowSelection, + accentColor, + borderRadius, + props.style, + prepareRow, + )} + + ); +}; + +export const EmptyRow = (props: { style?: CSSProperties }) => { + const { + accentColor, + borderRadius, + columns, + multiRowSelection, + prepareRow, + rows, + width, + } = useContext(BodyContext); + + return renderEmptyRows( + 1, + columns, + width, + rows, + multiRowSelection, + accentColor, + borderRadius, + props.style, + prepareRow, + )?.[0]; +}; diff --git a/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx b/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx new file mode 100644 index 0000000000..c99f82674d --- /dev/null +++ b/app/client/src/widgets/TableWidgetV2/component/TableBody/index.tsx @@ -0,0 +1,155 @@ +import React, { Ref } from "react"; +import { + Row as ReactTableRowType, + TableBodyPropGetter, + TableBodyProps, +} from "react-table"; +import { FixedSizeList, ListChildComponentProps, areEqual } from "react-window"; +import { WIDGET_PADDING } from "constants/WidgetConstants"; +import { EmptyRows, EmptyRow, Row } from "./Row"; +import { ReactTableColumnProps, TableSizes } from "../Constants"; + +type BodyContextType = { + accentColor: string; + borderRadius: string; + multiRowSelection: boolean; + prepareRow?(row: ReactTableRowType>): void; + selectTableRow?: (row: { + original: Record; + index: number; + }) => void; + selectedRowIndex: number; + selectedRowIndices: number[]; + columns: ReactTableColumnProps[]; + width: number; + rows: ReactTableRowType>[]; + primaryColumnId?: string; +}; + +export const BodyContext = React.createContext({ + accentColor: "", + borderRadius: "", + multiRowSelection: false, + selectedRowIndex: -1, + selectedRowIndices: [], + columns: [], + width: 0, + rows: [], + primaryColumnId: "", +}); + +const rowRenderer = React.memo((rowProps: ListChildComponentProps) => { + const { data, index, style } = rowProps; + + if (index < data.length) { + const row = data[index]; + + return ( + + ); + } else { + return ; + } +}, areEqual); + +type BodyPropsType = { + getTableBodyProps( + propGetter?: TableBodyPropGetter> | undefined, + ): TableBodyProps; + pageSize: number; + rows: ReactTableRowType>[]; + height: number; + tableSizes: TableSizes; +}; + +const TableVirtualBodyComponent = React.forwardRef( + (props: BodyPropsType, ref: Ref) => { + return ( +
+ + {rowRenderer} + +
+ ); + }, +); + +const TableBodyComponent = React.forwardRef( + (props: BodyPropsType, ref: Ref) => { + return ( +
+ {props.rows.map((row, index) => { + return ; + })} + {props.pageSize > props.rows.length && ( + + )} +
+ ); + }, +); + +export const TableBody = React.forwardRef( + ( + props: BodyPropsType & BodyContextType & { useVirtual: boolean }, + ref: Ref, + ) => { + const { + accentColor, + borderRadius, + columns, + multiRowSelection, + prepareRow, + primaryColumnId, + rows, + selectedRowIndex, + selectedRowIndices, + selectTableRow, + useVirtual, + width, + ...restOfProps + } = props; + + return ( + + {useVirtual ? ( + + ) : ( + + )} + + ); + }, +); diff --git a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx index f6bed385c9..6fb88d340b 100644 --- a/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/TableStyledWrappers.tsx @@ -11,6 +11,7 @@ import { CellAlignment, VerticalAlignment, scrollbarOnHoverCSS, + MULTISELECT_CHECKBOX_WIDTH, } from "./Constants"; import { Colors, Color } from "constants/Colors"; import { hideScrollbar, invisible } from "constants/DefaultTheme"; @@ -81,6 +82,9 @@ export const TableWrapper = styled.div<{ overflow-y: auto; ${hideScrollbar}; } + .tbody.no-scroll { + overflow: hidden; + } .tr { overflow: hidden; cursor: ${(props) => props.triggerRowSelection && "pointer"}; @@ -429,11 +433,11 @@ export const CellWrapper = styled.div<{ export const CellCheckboxWrapper = styled(CellWrapper)<{ isChecked?: boolean; - accentColor: string; - borderRadius: string; + accentColor?: string; + borderRadius?: string; }>` justify-content: center; - width: 40px; + width: ${MULTISELECT_CHECKBOX_WIDTH}px; height: auto; & > div { border-radius: ${({ borderRadius }) => borderRadius}; diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoToolTipComponent.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoToolTipComponent.tsx index 6e48e59567..8c862dfff7 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoToolTipComponent.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/AutoToolTipComponent.tsx @@ -39,15 +39,14 @@ function useToolTip( const [showTooltip, updateToolTip] = useState(false); useEffect(() => { - requestAnimationFrame(() => { - const element = ref.current?.querySelector("div") as HTMLDivElement; - if (element && element.offsetWidth < element.scrollWidth) { - updateToolTip(true); - } else { - updateToolTip(false); - } - }); - }, [children, ref.current]); + const element = ref.current?.querySelector("div") as HTMLDivElement; + + if (element && element.offsetWidth < element.scrollWidth) { + updateToolTip(true); + } else { + updateToolTip(false); + } + }, [children]); return showTooltip && children ? ( >[], - prepareRow: (row: Row>) => void, multiRowSelection = false, accentColor: string, borderRadius: string, + style?: CSSProperties, + prepareRow?: (row: Row>) => void, ) => { const rows: string[] = new Array(rowCount).fill(""); @@ -20,10 +21,13 @@ export const renderEmptyRows = ( const row = page[0]; return rows.map((item: string, index: number) => { - prepareRow(row); + prepareRow?.(row); const rowProps = { ...row.getRowProps(), - style: { display: "flex" }, + style: { + display: "flex", + ...style, + }, }; return (
@@ -41,26 +45,18 @@ export const renderEmptyRows = ( ? columns : new Array(3).fill({ width: tableWidth / 3, isHidden: false }); - return ( - <> - {rows.map((row: string, index: number) => { - return ( - - {multiRowSelection && - renderBodyCheckBoxCell(false, accentColor, borderRadius)} - {tableColumns.map((column: any, colIndex: number) => { - return ( - - ); - })} - - ); - })} - - ); + return rows.map((row: string, index: number) => { + return ( + + {multiRowSelection && + renderBodyCheckBoxCell(false, accentColor, borderRadius)} + {tableColumns.map((column: any, colIndex: number) => { + return ( + + ); + })} + + ); + }); } }; diff --git a/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx b/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx index ae0f724235..a78a8d0e17 100644 --- a/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx +++ b/app/client/src/widgets/TableWidgetV2/component/cellComponents/SelectionCheckboxCell.tsx @@ -7,8 +7,8 @@ import { CheckboxState } from "../Constants"; export const renderBodyCheckBoxCell = ( isChecked: boolean, - accentColor: string, - borderRadius: string, + accentColor?: string, + borderRadius?: string, ) => ( { // and we are not changing the columns manually. JSON.stringify(prev.columns) === JSON.stringify(next.columns) && equal(prev.editableCell, next.editableCell) && - prev.isEditableCellValid === next.isEditableCellValid + prev.isEditableCellValid === next.isEditableCellValid && + prev.primaryColumnId === next.primaryColumnId ); }); diff --git a/app/client/src/widgets/TableWidgetV2/widget/index.tsx b/app/client/src/widgets/TableWidgetV2/widget/index.tsx index 79ea45c46b..6c7ed3b946 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/index.tsx @@ -858,6 +858,7 @@ class TableWidgetV2 extends BaseWidget { isVisibleHeaderOptions ? Math.max(1, pageSize) : pageSize + 1 } prevPageClick={this.handlePrevPageClick} + primaryColumnId={this.props.primaryColumnId} searchKey={this.props.searchText} searchTableData={this.handleSearchTable} selectAllRow={this.handleAllRowSelect} diff --git a/app/client/yarn.lock b/app/client/yarn.lock index 7033f5dc8c..483274852d 100644 --- a/app/client/yarn.lock +++ b/app/client/yarn.lock @@ -7376,6 +7376,13 @@ fast-xml-parser@^3.17.5: version "3.17.5" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.5.tgz" +fastdom@^1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/fastdom/-/fastdom-1.0.11.tgz#f22984f9df6b9a6081e5ce2e49cfb5525daf198a" + integrity sha512-jl9MwXDFxhg354W4E3s1UMsLh3HWFuVMQiRUlXpHckcHRXQvUe76yzBf1Z7b+x5Ci4TUJ1KmynI9alGUXG95IQ== + dependencies: + strictdom "^1.0.1" + fastq@^1.6.0: version "1.13.0" resolved "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz" @@ -14262,6 +14269,11 @@ strict-event-emitter@^0.2.0: dependencies: events "^3.3.0" +strictdom@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/strictdom/-/strictdom-1.0.1.tgz#189de91649f73d44d59b8432efa68ef9d2659460" + integrity sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg== + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"