From 1f5f726e17551d50e945fdafc77124ce552ebbe7 Mon Sep 17 00:00:00 2001 From: Pooja Date: Fri, 24 Apr 2026 13:06:28 +0530 Subject: [PATCH] refactor(table): virtualise tables for perf for EditableTable components (#7810) --- .../components/EditableTable/StyledWrapper.js | 34 +- .../src/components/EditableTable/index.js | 328 ++++++++++-------- .../RequestPane/QueryParams/StyledWrapper.js | 8 + .../RequestPane/QueryParams/index.js | 2 +- .../RequestHeaders/StyledWrapper.js | 8 + .../RequestPane/RequestHeaders/index.js | 2 +- 6 files changed, 237 insertions(+), 145 deletions(-) diff --git a/packages/bruno-app/src/components/EditableTable/StyledWrapper.js b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js index 6f0c79426..c15cde52a 100644 --- a/packages/bruno-app/src/components/EditableTable/StyledWrapper.js +++ b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js @@ -1,10 +1,9 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; + display: block; + width: 100%; + isolation: isolate; &.is-resizing { cursor: col-resize !important; @@ -12,9 +11,9 @@ const StyledWrapper = styled.div` } .table-container { - overflow: auto; border-radius: ${(props) => props.theme.border.radius.base}; border: solid 1px ${(props) => props.theme.border.border0}; + overflow: clip; } table { @@ -80,6 +79,8 @@ const StyledWrapper = styled.div` tbody { tr { + height: 35px; + max-height: 35px; transition: background 0.1s ease; &:last-child td { @@ -87,6 +88,8 @@ const StyledWrapper = styled.div` } td { + height: 35px; + max-height: 35px; padding: 1px 10px !important; border-top: none !important; border-left: none !important; @@ -96,17 +99,23 @@ const StyledWrapper = styled.div` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + box-sizing: border-box; - &:last-child { - border-right: none; + > div:not(.drag-handle) { + height: 33px; + max-height: 33px; + overflow: hidden; } /* Handle CodeMirror editors overflow */ .cm-editor { max-width: 100%; + height: 33px !important; + max-height: 33px !important; .cm-scroller { overflow: hidden !important; + max-height: 33px; } .cm-content { @@ -185,12 +194,23 @@ const StyledWrapper = styled.div` } .drag-handle { + opacity: 0; + transition: opacity 0.1s ease; + display: flex; + align-items: center; + justify-content: center; + .icon-grip, .icon-minus { color: ${(props) => props.theme.colors.text.muted}; } } + tbody tr:hover .drag-handle, + tbody tr.drag-over .drag-handle { + opacity: 1; + } + select { background-color: transparent; color: ${(props) => props.theme.text}; diff --git a/packages/bruno-app/src/components/EditableTable/index.js b/packages/bruno-app/src/components/EditableTable/index.js index 67c5886c8..abf75d84d 100644 --- a/packages/bruno-app/src/components/EditableTable/index.js +++ b/packages/bruno-app/src/components/EditableTable/index.js @@ -1,10 +1,49 @@ -import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons'; import { Tooltip } from 'react-tooltip'; import { uuid } from 'utils/common'; import StyledWrapper from './StyledWrapper'; const MIN_COLUMN_WIDTH = 80; +const ROW_HEIGHT = 35; + +const findScrollParent = (element) => { + let parent = element?.parentElement; + while (parent) { + const { overflowY } = getComputedStyle(parent); + if (overflowY === 'auto' || overflowY === 'scroll') return parent; + parent = parent.parentElement; + } + return null; +}; + +const TableRow = React.memo( + ({ children, item, context, ...rest }) => { + const rowIndex = Number(rest['data-item-index']); + const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context; + const isEmpty = isLastEmptyRow(item, rowIndex); + const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount; + const isDragOver = canDrag && dragOverRow === rowIndex; + const existingClass = rest.className || ''; + const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass; + + return ( + onDragStart(e, rowIndex) : undefined} + onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined} + onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined} + onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined} + onDragEnd={canDrag ? onDragEnd : undefined} + > + {children} + + ); + } +); const EditableTable = ({ tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange @@ -25,13 +64,24 @@ const EditableTable = ({ columnWidths, onColumnWidthsChange }) => { - const tableRef = useRef(null); + const wrapperRef = useRef(null); + const virtuosoRef = useRef(null); const emptyRowUidRef = useRef(null); - const [hoveredRow, setHoveredRow] = useState(null); + const prevRowCountRef = useRef(0); const [resizing, setResizing] = useState(null); const [tableHeight, setTableHeight] = useState(0); + const [scrollParent, setScrollParent] = useState(null); + const [dragOverRow, setDragOverRow] = useState(null); const widths = columnWidths || {}; + useLayoutEffect(() => { + setScrollParent(findScrollParent(wrapperRef.current)); + }, []); + + const handleTotalHeightChanged = useCallback((h) => { + setTableHeight(h); + }, []); + const handleColumnWidthsChange = useCallback((newWidths) => { onColumnWidthsChange?.(newWidths); }, [onColumnWidthsChange]); @@ -71,7 +121,7 @@ const EditableTable = ({ const handleMouseUp = () => { // Convert pixel widths to percentages for responsive scaling - const table = tableRef.current?.querySelector('table'); + const table = wrapperRef.current?.querySelector('table'); if (table) { const tableWidth = table.offsetWidth; const headerCells = table.querySelectorAll('thead td'); @@ -103,23 +153,6 @@ const EditableTable = ({ document.addEventListener('mouseup', handleMouseUp); }, [columns, showCheckbox, widths, handleColumnWidthsChange]); - // Track table height for resize handles - useEffect(() => { - const table = tableRef.current?.querySelector('table'); - if (!table) return; - - const updateHeight = () => { - setTableHeight(table.offsetHeight); - }; - - updateHeight(); - - const resizeObserver = new ResizeObserver(updateHeight); - resizeObserver.observe(table); - - return () => resizeObserver.disconnect(); - }, [rows.length]); - const getColumnWidth = useCallback((column) => { return widths[column.key] || column.width || 'auto'; }, [widths]); @@ -179,6 +212,16 @@ const EditableTable = ({ return index === rowsWithEmpty.length - 1 && isEmptyRow(row); }, [rowsWithEmpty.length, isEmptyRow, showAddRow]); + useEffect(() => { + if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) { + virtuosoRef.current?.scrollToIndex({ + index: rowsWithEmpty.length - 1, + behavior: 'smooth' + }); + } + prevRowCountRef.current = rowsWithEmpty.length; + }, [rowsWithEmpty.length]); + const handleValueChange = useCallback((rowUid, key, value) => { const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid); if (rowIndex === -1) return; @@ -245,28 +288,31 @@ const EditableTable = ({ const handleDragOver = useCallback((e, index) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - setHoveredRow(index); + setDragOverRow((prev) => (prev === index ? prev : index)); }, []); + const handleDragLeave = useCallback((e, index) => { + if (e.currentTarget.contains(e.relatedTarget)) return; + setDragOverRow((prev) => (prev === index ? null : prev)); + }, []); + + const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length; + const handleDrop = useCallback((e, toIndex) => { e.preventDefault(); + setDragOverRow(null); const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (fromIndex !== toIndex && onReorder) { - const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty; - const updatedOrder = [...reorderableRows]; - const [movedRow] = updatedOrder.splice(fromIndex, 1); - if (!movedRow) { - setHoveredRow(null); - return; - } - updatedOrder.splice(toIndex, 0, movedRow); - onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) }); - } - setHoveredRow(null); + if (fromIndex === toIndex || !onReorder) return; + const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty; + const updatedOrder = [...reorderableRows]; + const [movedRow] = updatedOrder.splice(fromIndex, 1); + if (!movedRow) return; + updatedOrder.splice(toIndex, 0, movedRow); + onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) }); }, [onReorder, rowsWithEmpty, showAddRow]); const handleDragEnd = useCallback(() => { - setHoveredRow(null); + setDragOverRow(null); }, []); const renderCell = useCallback((column, row, rowIndex) => { @@ -323,109 +369,119 @@ const EditableTable = ({ ); }, [isLastEmptyRow, getRowError, handleValueChange]); - const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length; + const virtuosoContext = useMemo(() => ({ + reorderable, + reorderableRowCount, + isLastEmptyRow, + dragOverRow, + onDragStart: handleDragStart, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + onDragEnd: handleDragEnd + }), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]); + + const fixedHeaderContent = useCallback(() => ( + + {showCheckbox && ( + {checkboxLabel} + )} + {columns.map((column, colIndex) => ( + + {column.name} + {colIndex < columns.length - 1 && ( +
0 ? `${tableHeight}px` : undefined }} + onMouseDown={(e) => handleResizeStart(e, column.key)} + /> + )} + + ))} + {showDelete && ( + + )} + + ), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]); + + const itemContent = useCallback((rowIndex, row) => { + const isEmpty = isLastEmptyRow(row, rowIndex); + const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount; + + return ( + <> + {showCheckbox && ( + + {reorderable && canDrag && ( +
+ + +
+ )} + {!isEmpty && ( + handleCheckboxChange(row.uid, e.target.checked)} + /> + )} + + )} + {columns.map((column) => ( + + {renderCell(column, row, rowIndex)} + + ))} + {showDelete && ( + + {!isEmpty && ( + + )} + + )} + + ); + }, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]); return ( - -
- - - - {showCheckbox && ( - - )} - {columns.map((column, colIndex) => ( - - ))} - {showDelete && ( - - )} - - - - {rowsWithEmpty.map((row, rowIndex) => { - const isEmpty = isLastEmptyRow(row, rowIndex); - const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount; - - return ( - handleDragStart(e, rowIndex) : undefined} - onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined} - onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined} - onDragEnd={canDrag ? handleDragEnd : undefined} - onMouseEnter={() => setHoveredRow(rowIndex)} - onMouseLeave={() => setHoveredRow(null)} - > - {showCheckbox && ( - - )} - {columns.map((column) => ( - - ))} - {showDelete && ( - - )} - - ); - })} - -
{checkboxLabel} - {column.name} - {colIndex < columns.length - 1 && ( -
0 ? `${tableHeight}px` : undefined }} - onMouseDown={(e) => handleResizeStart(e, column.key)} - /> - )} -
- {reorderable && canDrag && ( -
- {hoveredRow === rowIndex && ( - <> - - - - )} -
- )} - {!isEmpty && ( - handleCheckboxChange(row.uid, e.target.checked)} - /> - )} -
- {renderCell(column, row, rowIndex)} - - {!isEmpty && ( - - )} -
-
+ + item.uid} + fixedHeaderContent={fixedHeaderContent} + itemContent={itemContent} + /> ); }; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js index 38ef5cc3d..ed140d79c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js @@ -38,6 +38,14 @@ const Wrapper = styled.div` } } + .bulk-edit-bar { + position: sticky; + bottom: 0; + background: ${(props) => props.theme.bg}; + padding-top: 8px; + padding-bottom: 4px; + } + input[type='text'] { width: 100%; border: solid 1px transparent; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index 1cd6c2cc7..01165d014 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -160,7 +160,7 @@ const QueryParams = ({ item, collection }) => { columnWidths={queryParamsWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)} /> -
+
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js index 8a47eb68e..bb4aa404e 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js @@ -29,6 +29,14 @@ const Wrapper = styled.div` } } + .bulk-edit-bar { + position: sticky; + bottom: 0; + background: ${(props) => props.theme.bg}; + padding-top: 8px; + padding-bottom: 4px; + } + input[type='text'] { width: 100%; border: solid 1px transparent; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index f23bafbae..939a9b3bd 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -145,7 +145,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { columnWidths={headersWidths} onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)} /> -
+