refactor(table): virtualise tables for perf for EditableTable components (#7810)

This commit is contained in:
Pooja
2026-04-24 13:06:28 +05:30
committed by GitHub
parent 9501a14bf8
commit 1f5f726e17
6 changed files with 237 additions and 145 deletions

View File

@@ -1,10 +1,9 @@
import styled from 'styled-components'; import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
display: flex; display: block;
flex-direction: column; width: 100%;
flex: 1; isolation: isolate;
overflow: hidden;
&.is-resizing { &.is-resizing {
cursor: col-resize !important; cursor: col-resize !important;
@@ -12,9 +11,9 @@ const StyledWrapper = styled.div`
} }
.table-container { .table-container {
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base}; border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0}; border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
} }
table { table {
@@ -80,6 +79,8 @@ const StyledWrapper = styled.div`
tbody { tbody {
tr { tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease; transition: background 0.1s ease;
&:last-child td { &:last-child td {
@@ -87,6 +88,8 @@ const StyledWrapper = styled.div`
} }
td { td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important; padding: 1px 10px !important;
border-top: none !important; border-top: none !important;
border-left: none !important; border-left: none !important;
@@ -96,17 +99,23 @@ const StyledWrapper = styled.div`
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
box-sizing: border-box;
&:last-child { > div:not(.drag-handle) {
border-right: none; height: 33px;
max-height: 33px;
overflow: hidden;
} }
/* Handle CodeMirror editors overflow */ /* Handle CodeMirror editors overflow */
.cm-editor { .cm-editor {
max-width: 100%; max-width: 100%;
height: 33px !important;
max-height: 33px !important;
.cm-scroller { .cm-scroller {
overflow: hidden !important; overflow: hidden !important;
max-height: 33px;
} }
.cm-content { .cm-content {
@@ -185,12 +194,23 @@ const StyledWrapper = styled.div`
} }
.drag-handle { .drag-handle {
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
.icon-grip, .icon-grip,
.icon-minus { .icon-minus {
color: ${(props) => props.theme.colors.text.muted}; color: ${(props) => props.theme.colors.text.muted};
} }
} }
tbody tr:hover .drag-handle,
tbody tr.drag-over .drag-handle {
opacity: 1;
}
select { select {
background-color: transparent; background-color: transparent;
color: ${(props) => props.theme.text}; color: ${(props) => props.theme.text};

View File

@@ -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 { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip'; import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80; 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 (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => 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}
</tr>
);
}
);
const EditableTable = ({ const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
@@ -25,13 +64,24 @@ const EditableTable = ({
columnWidths, columnWidths,
onColumnWidthsChange onColumnWidthsChange
}) => { }) => {
const tableRef = useRef(null); const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const emptyRowUidRef = useRef(null); const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null); const prevRowCountRef = useRef(0);
const [resizing, setResizing] = useState(null); const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0); const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {}; const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleColumnWidthsChange = useCallback((newWidths) => { const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths); onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]); }, [onColumnWidthsChange]);
@@ -71,7 +121,7 @@ const EditableTable = ({
const handleMouseUp = () => { const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling // Convert pixel widths to percentages for responsive scaling
const table = tableRef.current?.querySelector('table'); const table = wrapperRef.current?.querySelector('table');
if (table) { if (table) {
const tableWidth = table.offsetWidth; const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td'); const headerCells = table.querySelectorAll('thead td');
@@ -103,23 +153,6 @@ const EditableTable = ({
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]); }, [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) => { const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto'; return widths[column.key] || column.width || 'auto';
}, [widths]); }, [widths]);
@@ -179,6 +212,16 @@ const EditableTable = ({
return index === rowsWithEmpty.length - 1 && isEmptyRow(row); return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]); }, [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 handleValueChange = useCallback((rowUid, key, value) => {
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid); const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return; if (rowIndex === -1) return;
@@ -245,28 +288,31 @@ const EditableTable = ({
const handleDragOver = useCallback((e, index) => { const handleDragOver = useCallback((e, index) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; 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) => { const handleDrop = useCallback((e, toIndex) => {
e.preventDefault(); e.preventDefault();
setDragOverRow(null);
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex !== toIndex && onReorder) { if (fromIndex === toIndex || !onReorder) return;
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty; const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows]; const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1); const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) { if (!movedRow) return;
setHoveredRow(null); updatedOrder.splice(toIndex, 0, movedRow);
return; onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setHoveredRow(null);
}, [onReorder, rowsWithEmpty, showAddRow]); }, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setHoveredRow(null); setDragOverRow(null);
}, []); }, []);
const renderCell = useCallback((column, row, rowIndex) => { const renderCell = useCallback((column, row, rowIndex) => {
@@ -323,109 +369,119 @@ const EditableTable = ({
); );
}, [isLastEmptyRow, getRowError, handleValueChange]); }, [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(() => (
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
), [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 && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</>
);
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
return ( return (
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}> <StyledWrapper
<div className="table-container" ref={tableRef} data-testid={testId}> ref={wrapperRef}
<table> data-testid={testId}
<thead> className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
<tr> >
{showCheckbox && ( <TableVirtuoso
<td className="text-center">{checkboxLabel}</td> ref={virtuosoRef}
)} className="table-container"
{columns.map((column, colIndex) => ( customScrollParent={scrollParent || undefined}
<td data={rowsWithEmpty}
key={column.key} components={{ TableRow }}
style={{ width: getColumnWidth(column) }} context={virtuosoContext}
> defaultItemHeight={ROW_HEIGHT}
<span className="column-name">{column.name}</span> totalListHeightChanged={handleTotalHeightChanged}
{colIndex < columns.length - 1 && ( computeItemKey={(_, item) => item.uid}
<div fixedHeaderContent={fixedHeaderContent}
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`} itemContent={itemContent}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }} />
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => 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 && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@@ -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'] { input[type='text'] {
width: 100%; width: 100%;
border: solid 1px transparent; border: solid 1px transparent;

View File

@@ -160,7 +160,7 @@ const QueryParams = ({ item, collection }) => {
columnWidths={queryParamsWidths} columnWidths={queryParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)} onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
/> />
<div className="flex justify-end mt-2"> <div className="bulk-edit-bar flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}> <button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit Bulk Edit
</button> </button>

View File

@@ -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'] { input[type='text'] {
width: 100%; width: 100%;
border: solid 1px transparent; border: solid 1px transparent;

View File

@@ -145,7 +145,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
columnWidths={headersWidths} columnWidths={headersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)} onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)}
/> />
<div className="flex justify-end mt-2"> <div className="bulk-edit-bar flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}> <button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit Bulk Edit
</button> </button>