mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
refactor(table): virtualise tables for perf for EditableTable components (#7810)
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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 (
|
||||
<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 = ({
|
||||
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(() => (
|
||||
<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 (
|
||||
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
|
||||
<div className="table-container" ref={tableRef} data-testid={testId}>
|
||||
<table>
|
||||
<thead>
|
||||
<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>
|
||||
</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
|
||||
ref={wrapperRef}
|
||||
data-testid={testId}
|
||||
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
|
||||
>
|
||||
<TableVirtuoso
|
||||
ref={virtuosoRef}
|
||||
className="table-container"
|
||||
customScrollParent={scrollParent || undefined}
|
||||
data={rowsWithEmpty}
|
||||
components={{ TableRow }}
|
||||
context={virtuosoContext}
|
||||
defaultItemHeight={ROW_HEIGHT}
|
||||
totalListHeightChanged={handleTotalHeightChanged}
|
||||
computeItemKey={(_, item) => item.uid}
|
||||
fixedHeaderContent={fixedHeaderContent}
|
||||
itemContent={itemContent}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -160,7 +160,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
columnWidths={queryParamsWidths}
|
||||
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}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -145,7 +145,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
|
||||
columnWidths={headersWidths}
|
||||
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}>
|
||||
Bulk Edit
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user