From b09a073c292b7f75e3f9c97f50575f7fb505f17f Mon Sep 17 00:00:00 2001 From: Waleed Date: Tue, 24 Mar 2026 10:15:48 -0700 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .../app/api/table/[tableId]/metadata/route.ts | 1 + .../[tableId]/components/table/table.tsx | 272 +++++++++++++++--- apps/sim/lib/table/types.ts | 1 + 3 files changed, 241 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/metadata/route.ts b/apps/sim/app/api/table/[tableId]/metadata/route.ts index 5ae158e334..29bed2f382 100644 --- a/apps/sim/app/api/table/[tableId]/metadata/route.ts +++ b/apps/sim/app/api/table/[tableId]/metadata/route.ts @@ -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(), }), }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 37d4f91786..39e2996f40 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -196,6 +196,18 @@ export function Table({ const columnWidthsRef = useRef(columnWidths) columnWidthsRef.current = columnWidths const [resizingColumn, setResizingColumn] = useState(null) + const [columnOrder, setColumnOrder] = useState(null) + const columnOrderRef = useRef(columnOrder) + columnOrderRef.current = columnOrder + const [dragColumnName, setDragColumnName] = useState(null) + const dragColumnNameRef = useRef(dragColumnName) + dragColumnNameRef.current = dragColumnName + const [dropTargetColumnName, setDropTargetColumnName] = useState(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(null) const scrollRef = useRef(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( () => - 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( - () => , - [columns, handleFilterApply] + () => , + [displayColumns, handleFilterApply] ) const activeSortState = useMemo(() => { @@ -1501,7 +1624,7 @@ export function Table({ ) : ( - + )} {isLoadingTable ? ( @@ -1532,7 +1655,7 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {columns.map((column) => ( + {displayColumns.map((column) => ( ))} {userPermissions.canEdit && ( @@ -1578,7 +1708,7 @@ export function Table({ 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(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 ( - + + {isDropTarget && dropSide === 'left' && ( +
+ )} + {isDropTarget && dropSide === 'right' && ( +
+ )} {isRenaming ? (
@@ -2533,7 +2737,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({