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({