feat(table): column drag-and-drop reorder (#3738)

* feat(table): column drag-and-drop reorder

* fix(table): remove duplicate onDragEnd call from handleDrop

* fix(table): persist columnOrder on rename/delete and defer delete to onSuccess

* fix(table): prevent stale refs during column drag operations

Fix two bugs in column drag-and-drop:
1. Stale columnWidths ref during rename - compute updated widths inline
   before passing to updateMetadata
2. Escape-cancelled drag still reorders - update dropTargetColumnNameRef
   directly in handleColumnDragLeave to prevent handleColumnDragEnd from
   reading stale ref value

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(table): insert column at correct side when anchor is unordered

When the anchor column isn't in columnOrder, add it first then insert
the new column relative to it, so 'right' insertions appear after the
anchor as expected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-03-24 10:15:48 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 8d93c850ba
commit b09a073c29
3 changed files with 241 additions and 33 deletions
@@ -13,6 +13,7 @@ const MetadataSchema = z.object({
workspaceId: z.string().min(1, 'Workspace ID is required'),
metadata: z.object({
columnWidths: z.record(z.number().positive()).optional(),
columnOrder: z.array(z.string()).optional(),
}),
})
@@ -196,6 +196,18 @@ export function Table({
const columnWidthsRef = useRef(columnWidths)
columnWidthsRef.current = columnWidths
const [resizingColumn, setResizingColumn] = useState<string | null>(null)
const [columnOrder, setColumnOrder] = useState<string[] | null>(null)
const columnOrderRef = useRef(columnOrder)
columnOrderRef.current = columnOrder
const [dragColumnName, setDragColumnName] = useState<string | null>(null)
const dragColumnNameRef = useRef(dragColumnName)
dragColumnNameRef.current = dragColumnName
const [dropTargetColumnName, setDropTargetColumnName] = useState<string | null>(null)
const dropTargetColumnNameRef = useRef(dropTargetColumnName)
dropTargetColumnNameRef.current = dropTargetColumnName
const [dropSide, setDropSide] = useState<'left' | 'right'>('left')
const dropSideRef = useRef(dropSide)
dropSideRef.current = dropSide
const metadataSeededRef = useRef(false)
const containerRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -239,6 +251,23 @@ export function Table({
[tableData?.schema?.columns]
)
const displayColumns = useMemo(() => {
if (!columnOrder || columnOrder.length === 0) return columns
const colMap = new Map(columns.map((c) => [c.name, c]))
const ordered: ColumnDefinition[] = []
for (const name of columnOrder) {
const col = colMap.get(name)
if (col) {
ordered.push(col)
colMap.delete(name)
}
}
for (const col of colMap.values()) {
ordered.push(col)
}
return ordered
}, [columns, columnOrder])
const maxPosition = useMemo(() => (rows.length > 0 ? rows[rows.length - 1].position : -1), [rows])
const maxPositionRef = useRef(maxPosition)
maxPositionRef.current = maxPosition
@@ -258,23 +287,23 @@ export function Table({
[selectionAnchor, selectionFocus]
)
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : columns.length
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length
const tableWidth = useMemo(() => {
const colsWidth = isLoadingTable
? displayColCount * COL_WIDTH
: columns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
: displayColumns.reduce((sum, col) => sum + (columnWidths[col.name] ?? COL_WIDTH), 0)
return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH
}, [isLoadingTable, displayColCount, columns, columnWidths])
}, [isLoadingTable, displayColCount, displayColumns, columnWidths])
const resizeIndicatorLeft = useMemo(() => {
if (!resizingColumn) return 0
let left = CHECKBOX_COL_WIDTH
for (const col of columns) {
for (const col of displayColumns) {
left += columnWidths[col.name] ?? COL_WIDTH
if (col.name === resizingColumn) return left
}
return 0
}, [resizingColumn, columns, columnWidths])
}, [resizingColumn, displayColumns, columnWidths])
const isAllRowsSelected = useMemo(() => {
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
@@ -289,14 +318,15 @@ export function Table({
normalizedSelection.startRow === 0 &&
normalizedSelection.endRow === maxPosition &&
normalizedSelection.startCol === 0 &&
normalizedSelection.endCol === columns.length - 1
normalizedSelection.endCol === displayColumns.length - 1
)
}, [checkedRows, normalizedSelection, maxPosition, columns.length, rows])
}, [checkedRows, normalizedSelection, maxPosition, displayColumns.length, rows])
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
isAllRowsSelectedRef.current = isAllRowsSelected
const columnsRef = useRef(columns)
const columnsRef = useRef(displayColumns)
const schemaColumnsRef = useRef(columns)
const rowsRef = useRef(rows)
const selectionAnchorRef = useRef(selectionAnchor)
const selectionFocusRef = useRef(selectionFocus)
@@ -304,7 +334,8 @@ export function Table({
const checkedRowsRef = useRef(checkedRows)
checkedRowsRef.current = checkedRows
columnsRef.current = columns
columnsRef.current = displayColumns
schemaColumnsRef.current = columns
rowsRef.current = rows
selectionAnchorRef.current = selectionAnchor
selectionFocusRef.current = selectionFocus
@@ -329,10 +360,20 @@ export function Table({
const columnRename = useInlineRename({
onSave: (columnName, newName) => {
pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName })
setColumnWidths((prev) => {
if (!(columnName in prev)) return prev
return { ...prev, [newName]: prev[columnName] }
})
let updatedWidths = columnWidthsRef.current
if (columnName in updatedWidths) {
const { [columnName]: width, ...rest } = updatedWidths
updatedWidths = { ...rest, [newName]: width }
setColumnWidths(updatedWidths)
}
const updatedOrder = columnOrderRef.current?.map((n) => (n === columnName ? newName : n))
if (updatedOrder) {
setColumnOrder(updatedOrder)
updateMetadataRef.current({
columnWidths: updatedWidths,
columnOrder: updatedOrder,
})
}
updateColumnMutation.mutate({ columnName, updates: { name: newName } })
},
})
@@ -607,11 +648,58 @@ export function Table({
updateMetadataRef.current({ columnWidths: columnWidthsRef.current })
}, [])
const handleColumnDragStart = useCallback((columnName: string) => {
setDragColumnName(columnName)
}, [])
const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => {
if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return
setDropTargetColumnName(columnName)
setDropSide(side)
}, [])
const handleColumnDragEnd = useCallback(() => {
const dragged = dragColumnNameRef.current
if (!dragged) return
const target = dropTargetColumnNameRef.current
const side = dropSideRef.current
if (target && dragged !== target) {
const cols = columnsRef.current
const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name)
const fromIndex = currentOrder.indexOf(dragged)
const toIndex = currentOrder.indexOf(target)
if (fromIndex !== -1 && toIndex !== -1) {
const newOrder = currentOrder.filter((n) => n !== dragged)
let insertIndex = newOrder.indexOf(target)
if (side === 'right') insertIndex += 1
newOrder.splice(insertIndex, 0, dragged)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
}
}
setDragColumnName(null)
setDropTargetColumnName(null)
}, [])
const handleColumnDragLeave = useCallback(() => {
dropTargetColumnNameRef.current = null
setDropTargetColumnName(null)
}, [])
useEffect(() => {
if (!tableData?.metadata?.columnWidths || metadataSeededRef.current) return
if (!tableData?.metadata || metadataSeededRef.current) return
if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return
metadataSeededRef.current = true
setColumnWidths(tableData.metadata.columnWidths)
}, [tableData?.metadata?.columnWidths])
if (tableData.metadata.columnWidths) {
setColumnWidths(tableData.metadata.columnWidths)
}
if (tableData.metadata.columnOrder) {
setColumnOrder(tableData.metadata.columnOrder)
}
}, [tableData?.metadata])
useEffect(() => {
const handleMouseUp = () => {
@@ -1214,7 +1302,7 @@ export function Table({
}, [])
const generateColumnName = useCallback(() => {
const existing = columnsRef.current.map((c) => c.name.toLowerCase())
const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase())
let name = 'untitled'
let i = 2
while (existing.includes(name.toLowerCase())) {
@@ -1226,7 +1314,7 @@ export function Table({
const handleAddColumn = useCallback(() => {
const name = generateColumnName()
const position = columnsRef.current.length
const position = schemaColumnsRef.current.length
addColumnMutation.mutate(
{ name, type: 'string' },
{
@@ -1250,9 +1338,30 @@ export function Table({
updateColumnMutation.mutate({ columnName, updates: { type: newType } })
}, [])
const insertColumnInOrder = useCallback(
(anchorColumn: string, newColumn: string, side: 'left' | 'right') => {
const order = columnOrderRef.current
if (!order) return
const newOrder = [...order]
let anchorIdx = newOrder.indexOf(anchorColumn)
if (anchorIdx === -1) {
newOrder.push(anchorColumn)
anchorIdx = newOrder.length - 1
}
const insertIdx = anchorIdx + (side === 'right' ? 1 : 0)
newOrder.splice(insertIdx, 0, newColumn)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
},
[]
)
const handleInsertColumnLeft = useCallback(
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
const name = generateColumnName()
addColumnMutation.mutate(
@@ -1260,16 +1369,17 @@ export function Table({
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position: index })
insertColumnInOrder(columnName, name, 'left')
},
}
)
},
[generateColumnName]
[generateColumnName, insertColumnInOrder]
)
const handleInsertColumnRight = useCallback(
(columnName: string) => {
const index = columnsRef.current.findIndex((c) => c.name === columnName)
const index = schemaColumnsRef.current.findIndex((c) => c.name === columnName)
if (index === -1) return
const name = generateColumnName()
const position = index + 1
@@ -1278,11 +1388,12 @@ export function Table({
{
onSuccess: () => {
pushUndoRef.current({ type: 'create-column', columnName: name, position })
insertColumnInOrder(columnName, name, 'right')
},
}
)
},
[generateColumnName]
[generateColumnName, insertColumnInOrder]
)
const handleToggleUnique = useCallback((columnName: string) => {
@@ -1310,8 +1421,20 @@ export function Table({
const handleDeleteColumnConfirm = useCallback(() => {
if (!deletingColumn) return
deleteColumnMutation.mutate(deletingColumn)
const columnToDelete = deletingColumn
setDeletingColumn(null)
deleteColumnMutation.mutate(columnToDelete, {
onSuccess: () => {
const order = columnOrderRef.current
if (!order) return
const newOrder = order.filter((n) => n !== columnToDelete)
setColumnOrder(newOrder)
updateMetadataRef.current({
columnWidths: columnWidthsRef.current,
columnOrder: newOrder,
})
},
})
}, [deletingColumn])
const handleSortChange = useCallback((column: string, direction: SortDirection) => {
@@ -1327,13 +1450,13 @@ export function Table({
}, [])
const columnOptions = useMemo<ColumnOption[]>(
() =>
columns.map((col) => ({
displayColumns.map((col) => ({
id: col.name,
label: col.name,
type: col.type,
icon: COLUMN_TYPE_ICONS[col.type],
})),
[columns]
[displayColumns]
)
const tableDataRef = useRef(tableData)
@@ -1404,8 +1527,8 @@ export function Table({
)
const filterElement = useMemo(
() => <TableFilter columns={columns} onApply={handleFilterApply} />,
[columns, handleFilterApply]
() => <TableFilter columns={displayColumns} onApply={handleFilterApply} />,
[displayColumns, handleFilterApply]
)
const activeSortState = useMemo(() => {
@@ -1501,7 +1624,7 @@ export function Table({
<col style={{ width: ADD_COL_WIDTH }} />
</colgroup>
) : (
<TableColGroup columns={columns} columnWidths={columnWidths} />
<TableColGroup columns={displayColumns} columnWidths={columnWidths} />
)}
<thead className='sticky top-0 z-10'>
{isLoadingTable ? (
@@ -1532,7 +1655,7 @@ export function Table({
checked={isAllRowsSelected}
onCheckedChange={handleSelectAllToggle}
/>
{columns.map((column) => (
{displayColumns.map((column) => (
<ColumnHeaderMenu
key={column.name}
column={column}
@@ -1553,6 +1676,13 @@ export function Table({
onResizeStart={handleColumnResizeStart}
onResize={handleColumnResize}
onResizeEnd={handleColumnResizeEnd}
isDragging={dragColumnName === column.name}
isDropTarget={dropTargetColumnName === column.name}
dropSide={dropTargetColumnName === column.name ? dropSide : undefined}
onDragStart={handleColumnDragStart}
onDragOver={handleColumnDragOver}
onDragEnd={handleColumnDragEnd}
onDragLeave={handleColumnDragLeave}
/>
))}
{userPermissions.canEdit && (
@@ -1578,7 +1708,7 @@ export function Table({
<PositionGapRows
count={gapCount}
startPosition={prevPosition + 1}
columns={columns}
columns={displayColumns}
normalizedSelection={normalizedSelection}
checkedRows={checkedRows}
firstRowUnderHeader={prevPosition === -1}
@@ -1589,7 +1719,7 @@ export function Table({
)}
<DataRow
row={row}
columns={columns}
columns={displayColumns}
rowIndex={row.position}
isFirstRow={row.position === 0}
editingColumnName={
@@ -2445,6 +2575,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResizeStart,
onResize,
onResizeEnd,
isDragging,
isDropTarget,
dropSide,
onDragStart,
onDragOver,
onDragEnd,
onDragLeave,
}: {
column: ColumnDefinition
readOnly?: boolean
@@ -2462,6 +2599,13 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
onResizeStart: (columnName: string) => void
onResize: (columnName: string, width: number) => void
onResizeEnd: () => void
isDragging?: boolean
isDropTarget?: boolean
dropSide?: 'left' | 'right'
onDragStart?: (columnName: string) => void
onDragOver?: (columnName: string, side: 'left' | 'right') => void
onDragEnd?: () => void
onDragLeave?: () => void
}) {
const renameInputRef = useRef<HTMLInputElement>(null)
@@ -2503,8 +2647,68 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
[column.name, onResizeStart, onResize, onResizeEnd]
)
const handleDragStart = useCallback(
(e: React.DragEvent) => {
if (readOnly || isRenaming) {
e.preventDefault()
return
}
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', column.name)
onDragStart?.(column.name)
},
[column.name, readOnly, isRenaming, onDragStart]
)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midX = rect.left + rect.width / 2
const side = e.clientX < midX ? 'left' : 'right'
onDragOver?.(column.name, side)
},
[column.name, onDragOver]
)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
const handleDragEnd = useCallback(() => {
onDragEnd?.()
}, [onDragEnd])
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
const th = e.currentTarget as HTMLElement
const related = e.relatedTarget as Node | null
if (related && th.contains(related)) return
onDragLeave?.()
},
[onDragLeave]
)
return (
<th className='relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle'>
<th
className={cn(
'relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
isDragging && 'opacity-40'
)}
draggable={!readOnly && !isRenaming}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onDragLeave={handleDragLeave}
>
{isDropTarget && dropSide === 'left' && (
<div className='pointer-events-none absolute top-0 bottom-0 left-[-1px] z-10 w-[2px] bg-[var(--selection)]' />
)}
{isDropTarget && dropSide === 'right' && (
<div className='pointer-events-none absolute top-0 right-[-1px] bottom-0 z-10 w-[2px] bg-[var(--selection)]' />
)}
{isRenaming ? (
<div className='flex h-full w-full min-w-0 items-center px-[8px] py-[7px]'>
<ColumnTypeIcon type={column.type} />
@@ -2533,7 +2737,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex h-full w-full min-w-0 cursor-pointer items-center px-[8px] py-[7px] outline-none'
className='flex h-full w-full min-w-0 cursor-grab items-center px-[8px] py-[7px] outline-none active:cursor-grabbing'
>
<ColumnTypeIcon type={column.type} />
<span className='ml-[6px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
@@ -2589,6 +2793,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
)}
<div
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
draggable={false}
onDragStart={(e) => e.stopPropagation()}
onPointerDown={handleResizePointerDown}
/>
</th>
+1
View File
@@ -35,6 +35,7 @@ export interface TableSchema {
/** UI-only metadata stored alongside the table definition. */
export interface TableMetadata {
columnWidths?: Record<string, number>
columnOrder?: string[]
}
export interface TableDefinition {