diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 63da0c91de0..66790f68af7 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -135,32 +135,11 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const [existingRow] = await db - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId) - ) - ) - .limit(1) - - if (!existingRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - - const mergedData = { - ...(existingRow.data as RowData), - ...(validated.data as RowData), - } - const updatedRow = await updateRow( { tableId, rowId, - data: mergedData, + data: validated.data as RowData, workspaceId: validated.workspaceId, }, table, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index c8712e44de7..af0d8525cc2 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -137,33 +137,11 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - // Fetch existing row to merge partial update - const [existingRow] = await db - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId) - ) - ) - .limit(1) - - if (!existingRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - - const mergedData = { - ...(existingRow.data as RowData), - ...(validated.data as RowData), - } - const updatedRow = await updateRow( { tableId, rowId, - data: mergedData, + data: validated.data as RowData, workspaceId: validated.workspaceId, }, table, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index ce207f48a29..630c60ef1a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -158,6 +158,7 @@ export const UserInput = forwardRef(function Us isLoading: isSending, }) const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) + const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading) const contextManagement = useContextManagement({ message: value }) @@ -232,7 +233,7 @@ export const UserInput = forwardRef(function Us setSelectedContexts: contextManagement.setSelectedContexts, }) - const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending + const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles const valueRef = useRef(value) valueRef.current = value @@ -507,6 +508,8 @@ export const UserInput = forwardRef(function Us if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() + // Mirror canSubmit's uploading guard; Enter reads refs, not rendered state. + if (filesRef.current.attachedFiles.some((f) => f.uploading)) return const hasSubmitPayload = valueRef.current.trim().length > 0 || filesRef.current.attachedFiles.some((file) => !file.uploading && file.key) 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 c24ad9e6b87..9265306abd3 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 @@ -1,7 +1,6 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { GripVertical } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { @@ -199,9 +198,11 @@ export function Table({ const [selectionAnchor, setSelectionAnchor] = useState(null) const [selectionFocus, setSelectionFocus] = useState(null) const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS) + const [isColumnSelection, setIsColumnSelection] = useState(false) const lastCheckboxRowRef = useRef(null) + const isColumnSelectionRef = useRef(false) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) - const [deletingColumn, setDeletingColumn] = useState(null) + const [deletingColumns, setDeletingColumns] = useState(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -250,7 +251,39 @@ export function Table({ const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) - const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId }) + const handleColumnOrderChange = useCallback((order: string[]) => { + setColumnOrder(order) + }, []) + + const handleColumnRename = useCallback((oldName: string, newName: string) => { + let updatedWidths = columnWidthsRef.current + if (oldName in updatedWidths) { + const { [oldName]: width, ...rest } = updatedWidths + updatedWidths = { ...rest, [newName]: width } + setColumnWidths(updatedWidths) + } + const updatedOrder = columnOrderRef.current?.map((n) => (n === oldName ? newName : n)) + if (updatedOrder) setColumnOrder(updatedOrder) + updateMetadataRef.current({ + columnWidths: updatedWidths, + ...(updatedOrder ? { columnOrder: updatedOrder } : {}), + }) + }, []) + + const getColumnWidths = useCallback(() => columnWidthsRef.current, []) + + const handleColumnWidthsChange = useCallback((widths: Record) => { + setColumnWidths(widths) + }, []) + + const { pushUndo, undo, redo } = useTableUndo({ + workspaceId, + tableId, + onColumnOrderChange: handleColumnOrderChange, + onColumnRename: handleColumnRename, + onColumnWidthsChange: handleColumnWidthsChange, + getColumnWidths, + }) const undoRef = useRef(undo) undoRef.current = undo const redoRef = useRef(redo) @@ -317,16 +350,30 @@ export function Table({ return 0 }, [resizingColumn, displayColumns, columnWidths]) - const dropIndicatorLeft = useMemo(() => { - if (!dropTargetColumnName) return null + const dropColumnBounds = useMemo(() => { + if (!dropTargetColumnName || !dragColumnName) return null + if (dropTargetColumnName === dragColumnName) return null + + const dragIndex = displayColumns.findIndex((c) => c.name === dragColumnName) + const targetIndex = displayColumns.findIndex((c) => c.name === dropTargetColumnName) + if (dragIndex === -1 || targetIndex === -1) return null + + const wouldBeNoOp = + (dropSide === 'right' && targetIndex === dragIndex - 1) || + (dropSide === 'left' && targetIndex === dragIndex + 1) + if (wouldBeNoOp) return null + let left = CHECKBOX_COL_WIDTH for (const col of displayColumns) { - if (dropSide === 'left' && col.name === dropTargetColumnName) return left - left += columnWidths[col.name] ?? COL_WIDTH - if (dropSide === 'right' && col.name === dropTargetColumnName) return left + const w = columnWidths[col.name] ?? COL_WIDTH + if (col.name === dropTargetColumnName) { + const lineLeft = dropSide === 'left' ? left : left + w + return { left, width: w, lineLeft } + } + left += w } return null - }, [dropTargetColumnName, dropSide, displayColumns, columnWidths]) + }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { @@ -362,6 +409,7 @@ export function Table({ rowsRef.current = rows selectionAnchorRef.current = selectionAnchor selectionFocusRef.current = selectionFocus + isColumnSelectionRef.current = isColumnSelection const deleteTableMutation = useDeleteTable(workspaceId) const renameTableMutation = useRenameTable(workspaceId) @@ -383,18 +431,7 @@ export function Table({ const columnRename = useInlineRename({ onSave: (columnName, newName) => { pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName }) - 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, - }) + handleColumnRename(columnName, newName) updateColumnMutation.mutate({ columnName, updates: { name: newName } }) }, }) @@ -411,7 +448,8 @@ export function Table({ } catch { setShowDeleteTableConfirm(false) } - }, [deleteTableMutation, tableId, router, workspaceId]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableId, router, workspaceId]) const toggleBooleanCell = useCallback( (rowId: string, columnName: string, currentValue: unknown) => { @@ -564,10 +602,25 @@ export function Table({ const rowIndex = Number.parseInt(td.getAttribute('data-row') || '-1', 10) const colIndex = Number.parseInt(td.getAttribute('data-col') || '-1', 10) if (rowIndex >= 0 && colIndex >= 0) { - setSelectionAnchor({ rowIndex, colIndex }) - setSelectionFocus(null) columnName = colIndex < columnsRef.current.length ? columnsRef.current[colIndex].name : null + + const sel = computeNormalizedSelection( + selectionAnchorRef.current, + selectionFocusRef.current + ) + const isWithinSelection = + sel !== null && + rowIndex >= sel.startRow && + rowIndex <= sel.endRow && + colIndex >= sel.startCol && + colIndex <= sel.endCol + + if (!isWithinSelection) { + setSelectionAnchor({ rowIndex, colIndex }) + setSelectionFocus(null) + setIsColumnSelection(false) + } } } baseHandleRowContextMenu(e, row, columnName) @@ -578,6 +631,7 @@ export function Table({ const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, shiftKey: boolean) => { setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) @@ -600,6 +654,7 @@ export function Table({ setEditingCell(null) setSelectionAnchor(null) setSelectionFocus(null) + setIsColumnSelection(false) if (shiftKey && lastCheckboxRowRef.current !== null) { const from = Math.min(lastCheckboxRowRef.current, rowIndex) @@ -631,20 +686,41 @@ export function Table({ setSelectionAnchor(null) setSelectionFocus(null) setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) + lastCheckboxRowRef.current = null + }, []) + + const handleColumnSelect = useCallback((colIndex: number, shiftKey: boolean) => { + const lastRow = maxPositionRef.current + if (lastRow < 0) return + + setEditingCell(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) lastCheckboxRowRef.current = null + + if (shiftKey && isColumnSelectionRef.current && selectionAnchorRef.current) { + setSelectionFocus({ rowIndex: lastRow, colIndex }) + } else { + setSelectionAnchor({ rowIndex: 0, colIndex }) + setSelectionFocus({ rowIndex: lastRow, colIndex }) + setIsColumnSelection(true) + } + + scrollRef.current?.focus({ preventScroll: true }) }, []) const handleSelectAllRows = useCallback(() => { const rws = rowsRef.current - if (rws.length === 0) return + const currentCols = columnsRef.current + if (rws.length === 0 || currentCols.length === 0) return setEditingCell(null) - setSelectionAnchor(null) - setSelectionFocus(null) - const all = new Set() - for (const row of rws) { - all.add(row.position) - } - setCheckedRows(all) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) + setSelectionFocus({ + rowIndex: maxPositionRef.current, + colIndex: currentCols.length - 1, + }) + setIsColumnSelection(false) scrollRef.current?.focus({ preventScroll: true }) }, []) @@ -669,8 +745,47 @@ export function Table({ updateMetadataRef.current({ columnWidths: columnWidthsRef.current }) }, []) + const handleColumnAutoResize = useCallback((columnName: string) => { + const cols = columnsRef.current + const colIndex = cols.findIndex((c) => c.name === columnName) + if (colIndex === -1) return + + const currentRows = rowsRef.current + let maxWidth = COL_WIDTH_MIN + + const measure = document.createElement('span') + measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap' + document.body.appendChild(measure) + + try { + measure.className = 'font-medium text-small' + measure.textContent = columnName + maxWidth = Math.max(maxWidth, measure.offsetWidth + 57) + + measure.className = 'text-small' + for (const row of currentRows) { + const val = row.data[columnName] + if (val == null) continue + measure.textContent = String(val) + maxWidth = Math.max(maxWidth, measure.offsetWidth + 17) + } + } finally { + document.body.removeChild(measure) + } + + const newWidth = Math.min(Math.ceil(maxWidth), 600) + setColumnWidths((prev) => ({ ...prev, [columnName]: newWidth })) + const updated = { ...columnWidthsRef.current, [columnName]: newWidth } + columnWidthsRef.current = updated + updateMetadataRef.current({ columnWidths: updated }) + }, []) + const handleColumnDragStart = useCallback((columnName: string) => { setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) }, []) const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { @@ -681,7 +796,13 @@ export function Table({ const handleColumnDragEnd = useCallback(() => { const dragged = dragColumnNameRef.current - if (!dragged) return + if (!dragged) { + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + return + } + dragColumnNameRef.current = null const target = dropTargetColumnNameRef.current const side = dropSideRef.current if (target && dragged !== target) { @@ -694,11 +815,19 @@ export function Table({ let insertIndex = newOrder.indexOf(target) if (side === 'right') insertIndex += 1 newOrder.splice(insertIndex, 0, dragged) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, - }) + const orderChanged = newOrder.some((name, i) => currentOrder[i] !== name) + if (orderChanged) { + pushUndoRef.current({ + type: 'reorder-columns', + previousOrder: currentOrder, + newOrder, + }) + setColumnOrder(newOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) + } } } setDragColumnName(null) @@ -711,6 +840,37 @@ export function Table({ setDropTargetColumnName(null) }, []) + const handleScrollDragOver = useCallback((e: React.DragEvent) => { + if (!dragColumnNameRef.current) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + + const scrollEl = scrollRef.current + if (!scrollEl) return + const scrollRect = scrollEl.getBoundingClientRect() + const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft + + const cols = columnsRef.current + let left = CHECKBOX_COL_WIDTH + for (const col of cols) { + const w = columnWidthsRef.current[col.name] ?? COL_WIDTH + if (cursorX < left + w) { + const midX = left + w / 2 + const side = cursorX < midX ? 'left' : 'right' + if (col.name !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { + setDropTargetColumnName(col.name) + setDropSide(side) + } + return + } + left += w + } + }, []) + + const handleScrollDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + useEffect(() => { if (!tableData?.metadata || metadataSeededRef.current) return if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return @@ -723,6 +883,16 @@ export function Table({ } }, [tableData?.metadata]) + useEffect(() => { + if (!isColumnSelection || !selectionAnchor) return + setSelectionFocus((prev) => { + if (!prev || prev.rowIndex !== maxPosition) { + return { rowIndex: maxPosition, colIndex: prev?.colIndex ?? selectionAnchor.colIndex } + } + return prev + }) + }, [isColumnSelection, maxPosition, selectionAnchor]) + useEffect(() => { const handleMouseUp = () => { isDraggingRef.current = false @@ -732,8 +902,10 @@ export function Table({ }, []) useEffect(() => { - if (!selectionAnchor) return - const { rowIndex, colIndex } = selectionAnchor + if (isColumnSelection) return + const target = selectionFocus ?? selectionAnchor + if (!target) return + const { rowIndex, colIndex } = target const rafId = requestAnimationFrame(() => { const cell = document.querySelector( `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` @@ -741,7 +913,7 @@ export function Table({ cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }) return () => cancelAnimationFrame(rafId) - }, [selectionAnchor]) + }, [selectionAnchor, selectionFocus, isColumnSelection]) const handleCellClick = useCallback((rowId: string, columnName: string) => { const column = columnsRef.current.find((c) => c.name === columnName) @@ -766,6 +938,7 @@ export function Table({ if (!column || column.type === 'boolean') return setSelectionFocus(null) + setIsColumnSelection(false) setEditingCell({ rowId, columnName }) setInitialCharacter(null) }, []) @@ -811,9 +984,19 @@ export function Table({ if (e.key === 'Escape') { e.preventDefault() + if (dragColumnNameRef.current) { + dragColumnNameRef.current = null + dropTargetColumnNameRef.current = null + dropSideRef.current = 'left' + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + return + } setSelectionAnchor(null) setSelectionFocus(null) setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null return } @@ -821,34 +1004,45 @@ export function Table({ if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault() const rws = rowsRef.current - if (rws.length > 0) { + const currentCols = columnsRef.current + if (rws.length > 0 && currentCols.length > 0) { setEditingCell(null) - setSelectionAnchor(null) - setSelectionFocus(null) - const all = new Set() - for (const row of rws) { - all.add(row.position) - } - setCheckedRows(all) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) + setSelectionFocus({ + rowIndex: maxPositionRef.current, + colIndex: currentCols.length - 1, + }) + setIsColumnSelection(false) } return } + if ((e.metaKey || e.ctrlKey) && e.key === ' ') { + const a = selectionAnchorRef.current + if (!a || editingCellRef.current) return + const lastRow = maxPositionRef.current + if (lastRow < 0) return + e.preventDefault() + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null + setSelectionAnchor({ rowIndex: 0, colIndex: a.colIndex }) + setSelectionFocus({ rowIndex: lastRow, colIndex: a.colIndex }) + setIsColumnSelection(true) + return + } + if (e.key === ' ' && e.shiftKey) { const a = selectionAnchorRef.current if (!a || editingCellRef.current) return + const currentCols = columnsRef.current + if (currentCols.length === 0) return e.preventDefault() - setSelectionFocus(null) - setCheckedRows((prev) => { - const next = new Set(prev) - if (next.has(a.rowIndex)) { - next.delete(a.rowIndex) - } else { - next.add(a.rowIndex) - } - return next - }) - lastCheckboxRowRef.current = a.rowIndex + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null + setIsColumnSelection(false) + setSelectionAnchor({ rowIndex: a.rowIndex, colIndex: 0 }) + setSelectionFocus({ rowIndex: a.rowIndex, colIndex: currentCols.length - 1 }) return } @@ -860,6 +1054,7 @@ export function Table({ const pMap = positionMapRef.current const currentCols = columnsRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] + const batchUpdates: Array<{ rowId: string; data: Record }> = [] for (const pos of checked) { const row = pMap.get(pos) if (!row) continue @@ -870,7 +1065,10 @@ export function Table({ updates[col.name] = null } undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) + } + if (batchUpdates.length > 0) { + batchUpdateRef.current({ updates: batchUpdates }) } if (undoCells.length > 0) { pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) @@ -939,6 +1137,7 @@ export function Table({ if (e.key === 'Tab') { e.preventDefault() setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) setSelectionFocus(null) @@ -948,6 +1147,7 @@ export function Table({ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null const focus = selectionFocusRef.current ?? anchor const origin = e.shiftKey ? focus : anchor @@ -979,6 +1179,97 @@ export function Table({ return } + if (e.key === 'Home') { + e.preventDefault() + setIsColumnSelection(false) + const jump = e.metaKey || e.ctrlKey + if (e.shiftKey) { + const focus = selectionFocusRef.current ?? anchor + setSelectionFocus({ rowIndex: jump ? 0 : focus.rowIndex, colIndex: 0 }) + } else { + setSelectionAnchor({ rowIndex: jump ? 0 : anchor.rowIndex, colIndex: 0 }) + setSelectionFocus(null) + } + return + } + + if (e.key === 'End') { + e.preventDefault() + setIsColumnSelection(false) + const jump = e.metaKey || e.ctrlKey + if (e.shiftKey) { + const focus = selectionFocusRef.current ?? anchor + setSelectionFocus({ + rowIndex: jump ? totalRows - 1 : focus.rowIndex, + colIndex: cols.length - 1, + }) + } else { + setSelectionAnchor({ + rowIndex: jump ? totalRows - 1 : anchor.rowIndex, + colIndex: cols.length - 1, + }) + setSelectionFocus(null) + } + return + } + + if (e.key === 'PageUp' || e.key === 'PageDown') { + e.preventDefault() + setIsColumnSelection(false) + const scrollEl = scrollRef.current + const viewportHeight = scrollEl ? scrollEl.clientHeight : ROW_HEIGHT_ESTIMATE * 10 + const rowsPerPage = Math.max(1, Math.floor(viewportHeight / ROW_HEIGHT_ESTIMATE)) + const direction = e.key === 'PageUp' ? -1 : 1 + const origin = e.shiftKey ? (selectionFocusRef.current ?? anchor) : anchor + const newRow = Math.max( + 0, + Math.min(totalRows - 1, origin.rowIndex + direction * rowsPerPage) + ) + if (e.shiftKey) { + setSelectionFocus({ rowIndex: newRow, colIndex: origin.colIndex }) + } else { + setSelectionAnchor({ rowIndex: newRow, colIndex: anchor.colIndex }) + setSelectionFocus(null) + } + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'd') { + e.preventDefault() + if (!canEditRef.current) return + const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + if (!sel || sel.startRow === sel.endRow) return + const pMap = positionMapRef.current + const sourceRow = pMap.get(sel.startRow) + if (!sourceRow) return + const undoCells: Array<{ + rowId: string + oldData: Record + newData: Record + }> = [] + for (let r = sel.startRow + 1; r <= sel.endRow; r++) { + const row = pMap.get(r) + if (!row) continue + const oldData: Record = {} + const newData: Record = {} + for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c < cols.length) { + const colName = cols[c].name + oldData[colName] = row.data[colName] ?? null + newData[colName] = sourceRow.data[colName] ?? null + } + } + undoCells.push({ rowId: row.id, oldData, newData }) + } + if (undoCells.length > 0) { + batchUpdateRef.current({ + updates: undoCells.map((c) => ({ rowId: c.rowId, data: c.newData })), + }) + pushUndoRef.current({ type: 'update-cells', cells: undoCells }) + } + return + } + if (e.key === 'Delete' || e.key === 'Backspace') { if (!canEditRef.current) return e.preventDefault() @@ -986,6 +1277,7 @@ export function Table({ if (!sel) return const pMap = positionMapRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] + const batchUpdates: Array<{ rowId: string; data: Record }> = [] for (let r = sel.startRow; r <= sel.endRow; r++) { const row = pMap.get(r) if (!row) continue @@ -999,7 +1291,10 @@ export function Table({ } } undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) + } + if (batchUpdates.length > 0) { + batchUpdateRef.current({ updates: batchUpdates }) } if (undoCells.length > 0) { pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) @@ -1061,6 +1356,7 @@ export function Table({ for (let r = sel.startRow; r <= sel.endRow; r++) { const cells: string[] = [] for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c >= cols.length) break const row = pMap.get(r) const value: unknown = row ? row.data[cols[c].name] : null if (value === null || value === undefined) { @@ -1084,6 +1380,7 @@ export function Table({ const cols = columnsRef.current const pMap = positionMapRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] + const batchUpdates: Array<{ rowId: string; data: Record }> = [] if (checked.size > 0) { e.preventDefault() @@ -1105,7 +1402,7 @@ export function Table({ updates[col.name] = null } undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) } e.clipboardData?.setData('text/plain', lines.join('\n')) } else { @@ -1138,11 +1435,14 @@ export function Table({ } lines.push(cells.join('\t')) undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) } e.clipboardData?.setData('text/plain', lines.join('\n')) } + if (batchUpdates.length > 0) { + batchUpdateRef.current({ updates: batchUpdates }) + } if (undoCells.length > 0) { pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) } @@ -1296,15 +1596,16 @@ export function Table({ return } - const oldValue = row.data[columnName] - const changed = !(oldValue === value) && !(oldValue === null && value === null) + const oldValue = row.data[columnName] ?? null + const normalizedValue = value ?? null + const changed = oldValue !== normalizedValue if (changed) { pushUndoRef.current({ type: 'update-cell', rowId, columnName, - previousValue: oldValue ?? null, + previousValue: oldValue, newValue: value, }) mutateRef.current({ rowId, data: { [columnName]: value } }) @@ -1349,15 +1650,22 @@ export function Table({ const handleChangeType = useCallback((columnName: string, newType: string) => { const column = columnsRef.current.find((c) => c.name === columnName) - if (column) { - pushUndoRef.current({ - type: 'update-column-type', - columnName, - previousType: column.type, - newType, - }) - } - updateColumnMutation.mutate({ columnName, updates: { type: newType } }) + const previousType = column?.type + updateColumnMutation.mutate( + { columnName, updates: { type: newType } }, + { + onSuccess: () => { + if (previousType) { + pushUndoRef.current({ + type: 'update-column-type', + columnName, + previousType, + newType, + }) + } + }, + } + ) }, []) const insertColumnInOrder = useCallback( @@ -1437,26 +1745,96 @@ export function Table({ ) const handleDeleteColumn = useCallback((columnName: string) => { - setDeletingColumn(columnName) + const cols = columnsRef.current + if (isColumnSelectionRef.current && selectionAnchorRef.current) { + const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) + if (sel && sel.startCol !== sel.endCol) { + const clickedIdx = cols.findIndex((c) => c.name === columnName) + if (clickedIdx >= sel.startCol && clickedIdx <= sel.endCol) { + const names: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c < cols.length) names.push(cols[c].name) + } + if (names.length > 0) { + setDeletingColumns(names) + return + } + } + } + } + setDeletingColumns([columnName]) }, []) const handleDeleteColumnConfirm = useCallback(() => { - if (!deletingColumn) return - const columnToDelete = deletingColumn - const orderAtDelete = columnOrderRef.current - setDeletingColumn(null) - deleteColumnMutation.mutate(columnToDelete, { - onSuccess: () => { - if (!orderAtDelete) return - const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, - }) - }, - }) - }, [deletingColumn]) + if (!deletingColumns || deletingColumns.length === 0) return + const columnsToDelete = [...deletingColumns] + setDeletingColumns(null) + + let currentOrder = columnOrderRef.current ? [...columnOrderRef.current] : null + const cols = schemaColumnsRef.current + const originalPositions = new Map< + string, + { position: number; def: (typeof cols)[number] | undefined } + >() + for (const name of columnsToDelete) { + const def = cols.find((c) => c.name === name) + originalPositions.set(name, { position: def ? cols.indexOf(def) : cols.length, def }) + } + const deletedOriginalPositions: number[] = [] + + const deleteNext = (index: number) => { + if (index >= columnsToDelete.length) return + const columnToDelete = columnsToDelete[index] + const entry = originalPositions.get(columnToDelete)! + const adjustedPosition = + entry.position - deletedOriginalPositions.filter((p) => p < entry.position).length + const currentRows = rowsRef.current + const cellData = currentRows + .filter((r) => r.data[columnToDelete] != null) + .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) + const previousWidth = columnWidthsRef.current[columnToDelete] ?? null + const orderSnapshot = currentOrder ? [...currentOrder] : null + + deleteColumnMutation.mutate(columnToDelete, { + onSuccess: () => { + deletedOriginalPositions.push(entry.position) + pushUndoRef.current({ + type: 'delete-column', + columnName: columnToDelete, + columnType: entry.def?.type ?? 'string', + columnPosition: adjustedPosition >= 0 ? adjustedPosition : cols.length, + columnUnique: entry.def?.unique ?? false, + columnRequired: entry.def?.required ?? false, + cellData, + previousOrder: orderSnapshot, + previousWidth, + }) + + const { [columnToDelete]: _removedWidth, ...cleanedWidths } = columnWidthsRef.current + setColumnWidths(cleanedWidths) + columnWidthsRef.current = cleanedWidths + + if (currentOrder) { + currentOrder = currentOrder.filter((n) => n !== columnToDelete) + setColumnOrder(currentOrder) + updateMetadataRef.current({ + columnWidths: cleanedWidths, + columnOrder: currentOrder, + }) + } else { + updateMetadataRef.current({ columnWidths: cleanedWidths }) + } + + deleteNext(index + 1) + }, + }) + } + + setSelectionAnchor(null) + setSelectionFocus(null) + setIsColumnSelection(false) + deleteNext(0) + }, [deletingColumns]) const handleSortChange = useCallback((column: string, direction: SortDirection) => { setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })) @@ -1668,6 +2046,8 @@ export function Table({ resizingColumn && 'select-none' )} data-table-scroll + onDragOver={handleScrollDragOver} + onDrop={handleScrollDrop} >
- {displayColumns.map((column) => ( + {displayColumns.map((column, idx) => ( = normalizedSelection.startCol && + idx <= normalizedSelection.endCol + } renameValue={ columnRename.editingId === column.name ? columnRename.editValue : '' } @@ -1727,6 +2114,7 @@ export function Table({ onRenameSubmit={columnRename.submitRename} onRenameCancel={columnRename.cancelRename} onRenameColumn={handleRenameColumn} + onColumnSelect={handleColumnSelect} onChangeType={handleChangeType} onInsertLeft={handleInsertColumnLeft} onInsertRight={handleInsertColumnRight} @@ -1735,7 +2123,7 @@ export function Table({ onResizeStart={handleColumnResizeStart} onResize={handleColumnResize} onResizeEnd={handleColumnResizeEnd} - isDragging={dragColumnName === column.name} + onAutoResize={handleColumnAutoResize} onDragStart={handleColumnDragStart} onDragOver={handleColumnDragOver} onDragEnd={handleColumnDragEnd} @@ -1812,11 +2200,17 @@ export function Table({ style={{ left: resizeIndicatorLeft }} /> )} - {dropIndicatorLeft !== null && ( -
+ {dropColumnBounds !== null && ( + <> +
+
+ )}
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( @@ -1908,25 +2302,45 @@ export function Table({ )} { - if (!open) setDeletingColumn(null) + if (!open) setDeletingColumns(null) }} > - Delete Column + + {deletingColumns && deletingColumns.length > 1 + ? `Delete ${deletingColumns.length} Columns` + : 'Delete Column'} +

- Are you sure you want to delete{' '} - {deletingColumn}?{' '} + {deletingColumns && deletingColumns.length > 1 ? ( + <> + Are you sure you want to delete{' '} + + {deletingColumns.length} columns + + ?{' '} + + ) : ( + <> + Are you sure you want to delete{' '} + + {deletingColumns?.[0]} + + ?{' '} + + )} - This will remove all data in this column. + This will remove all data in{' '} + {deletingColumns && deletingColumns.length > 1 ? 'these columns' : 'this column'}. {' '} - This action cannot be undone. + You can undo this action.

-
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index f449413794e..a7ac6ff729e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -104,8 +104,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { }, []) /** - * Processes and uploads files to S3 - * @param fileList - Files to process + * Uploads files in parallel so a slow file does not block faster ones queued + * behind it. All placeholders insert in a single state update for a stable row. */ const processFiles = useCallback( async (fileList: FileList) => { @@ -114,67 +114,69 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { return } - for (const file of Array.from(fileList)) { - let previewUrl: string | undefined - if (file.type.startsWith('image/')) { - previewUrl = URL.createObjectURL(file) - } - - const resolvedType = resolveFileType(file) - - const tempFile: AttachedFile = { - id: generateId(), - name: file.name, - size: file.size, - type: resolvedType, - path: '', - uploading: true, - previewUrl, - } - - setAttachedFiles((prev) => [...prev, tempFile]) - - try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'mothership') - if (workspaceId) { - formData.append('workspaceId', workspaceId) - } - - const uploadResponse = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }) - - if (!uploadResponse.ok) { - const errorData = await uploadResponse.json().catch(() => ({ - error: `Upload failed: ${uploadResponse.status}`, - })) - throw new Error(errorData.error || `Failed to upload file: ${uploadResponse.status}`) - } - - const uploadData = await uploadResponse.json() - - logger.info(`File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}`) + const files = Array.from(fileList) + if (files.length === 0) return + + const placeholders: AttachedFile[] = files.map((file) => ({ + id: generateId(), + name: file.name, + size: file.size, + type: resolveFileType(file), + path: '', + uploading: true, + previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, + })) + + setAttachedFiles((prev) => [...prev, ...placeholders]) + + await Promise.all( + files.map(async (file, i) => { + const placeholder = placeholders[i] + try { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'mothership') + if (workspaceId) { + formData.append('workspaceId', workspaceId) + } + + const uploadResponse = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }) + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json().catch(() => ({ + error: `Upload failed: ${uploadResponse.status}`, + })) + throw new Error(errorData.error || `Failed to upload file: ${uploadResponse.status}`) + } + + const uploadData = await uploadResponse.json() + + logger.info( + `File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}` + ) - setAttachedFiles((prev) => - prev.map((f) => - f.id === tempFile.id - ? { - ...f, - path: uploadData.fileInfo?.path || uploadData.path || uploadData.url, - key: uploadData.fileInfo?.key || uploadData.key, - uploading: false, - } - : f + setAttachedFiles((prev) => + prev.map((f) => + f.id === placeholder.id + ? { + ...f, + path: uploadData.fileInfo?.path || uploadData.path || uploadData.url, + key: uploadData.fileInfo?.key || uploadData.key, + uploading: false, + } + : f + ) ) - ) - } catch (error) { - logger.error(`File upload failed: ${error}`) - setAttachedFiles((prev) => prev.filter((f) => f.id !== tempFile.id)) - } - } + } catch (error) { + logger.error(`File upload failed: ${error}`) + if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) + setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) + } + }) + ) }, [userId, workspaceId] ) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 282761df980..7b0d233d884 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -676,6 +676,9 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { return res.json() }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateTableSchema(queryClient, workspaceId, tableId) }, diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 6090e84f1d8..accdd79b599 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -2,7 +2,7 @@ * Hook that connects the table undo/redo store to React Query mutations. */ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import { useAddTableColumn, @@ -14,6 +14,7 @@ import { useDeleteTableRows, useRenameTable, useUpdateColumn, + useUpdateTableMetadata, useUpdateTableRow, } from '@/hooks/queries/tables' import { runWithoutRecording, useTableUndoStore } from '@/stores/table/store' @@ -33,9 +34,20 @@ export function extractCreatedRowId(response: Record): string | interface UseTableUndoProps { workspaceId: string tableId: string + onColumnOrderChange?: (order: string[]) => void + onColumnRename?: (oldName: string, newName: string) => void + onColumnWidthsChange?: (widths: Record) => void + getColumnWidths?: () => Record } -export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { +export function useTableUndo({ + workspaceId, + tableId, + onColumnOrderChange, + onColumnRename, + onColumnWidthsChange, + getColumnWidths, +}: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) const popUndo = useTableUndoStore((s) => s.popUndo) const popRedo = useTableUndoStore((s) => s.popRedo) @@ -55,6 +67,16 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { const updateColumnMutation = useUpdateColumn({ workspaceId, tableId }) const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const renameTableMutation = useRenameTable(workspaceId) + const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) + + const onColumnOrderChangeRef = useRef(onColumnOrderChange) + onColumnOrderChangeRef.current = onColumnOrderChange + const onColumnRenameRef = useRef(onColumnRename) + onColumnRenameRef.current = onColumnRename + const onColumnWidthsChangeRef = useRef(onColumnWidthsChange) + onColumnWidthsChangeRef.current = onColumnWidthsChange + const getColumnWidthsRef = useRef(getColumnWidths) + getColumnWidthsRef.current = getColumnWidths useEffect(() => { return () => clear(tableId) @@ -180,7 +202,16 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { case 'create-column': { if (direction === 'undo') { - deleteColumnMutation.mutate(action.columnName) + deleteColumnMutation.mutate(action.columnName, { + onSuccess: () => { + const currentWidths = getColumnWidthsRef.current?.() ?? {} + if (action.columnName in currentWidths) { + const { [action.columnName]: _, ...rest } = currentWidths + onColumnWidthsChangeRef.current?.(rest) + updateMetadataMutation.mutate({ columnWidths: rest }) + } + }, + }) } else { addColumnMutation.mutate({ name: action.columnName, @@ -191,21 +222,89 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { break } - case 'rename-column': { + case 'delete-column': { if (direction === 'undo') { - updateColumnMutation.mutate({ - columnName: action.newName, - updates: { name: action.oldName }, - }) + addColumnMutation.mutate( + { + name: action.columnName, + type: action.columnType, + required: action.columnRequired, + unique: action.columnUnique, + position: action.columnPosition, + }, + { + onSuccess: () => { + if (action.cellData.length > 0) { + const updates = action.cellData.map((c) => ({ + rowId: c.rowId, + data: { [action.columnName]: c.value }, + })) + batchUpdateRowsMutation.mutate( + { updates }, + { + onError: (error) => { + logger.error('Failed to restore cell data on delete-column undo', { + columnName: action.columnName, + error, + }) + }, + } + ) + } + const metadata: Record = {} + if (action.previousOrder) { + onColumnOrderChangeRef.current?.(action.previousOrder) + metadata.columnOrder = action.previousOrder + } + if (action.previousWidth !== null) { + const merged = { + ...(getColumnWidthsRef.current?.() ?? {}), + [action.columnName]: action.previousWidth, + } + metadata.columnWidths = merged + onColumnWidthsChangeRef.current?.(merged) + } + if (Object.keys(metadata).length > 0) { + updateMetadataMutation.mutate(metadata) + } + }, + } + ) } else { - updateColumnMutation.mutate({ - columnName: action.oldName, - updates: { name: action.newName }, + deleteColumnMutation.mutate(action.columnName, { + onSuccess: () => { + const metadata: Record = {} + if (action.previousOrder) { + const newOrder = action.previousOrder.filter((n) => n !== action.columnName) + onColumnOrderChangeRef.current?.(newOrder) + metadata.columnOrder = newOrder + } + if (action.previousWidth !== null) { + const currentWidths = getColumnWidthsRef.current?.() ?? {} + const { [action.columnName]: _, ...rest } = currentWidths + metadata.columnWidths = rest + onColumnWidthsChangeRef.current?.(rest) + } + if (Object.keys(metadata).length > 0) { + updateMetadataMutation.mutate(metadata) + } + }, }) } break } + case 'rename-column': { + const fromName = direction === 'undo' ? action.newName : action.oldName + const toName = direction === 'undo' ? action.oldName : action.newName + updateColumnMutation.mutate({ + columnName: fromName, + updates: { name: toName }, + }) + onColumnRenameRef.current?.(fromName, toName) + break + } + case 'update-column-type': { const type = direction === 'undo' ? action.previousType : action.newType updateColumnMutation.mutate({ @@ -229,6 +328,13 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { renameTableMutation.mutate({ tableId: action.tableId, name }) break } + + case 'reorder-columns': { + const order = direction === 'undo' ? action.previousOrder : action.newOrder + onColumnOrderChangeRef.current?.(order) + updateMetadataMutation.mutate({ columnOrder: order }) + break + } } } catch (err) { logger.error('Failed to execute undo/redo action', { action, direction, err }) diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts new file mode 100644 index 00000000000..ef899da0f9f --- /dev/null +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { databaseMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { updateRow } from '../service' +import type { TableDefinition } from '../types' + +const EXISTING_ROW = { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Alice', age: 30 }, + position: 1, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +const TABLE: TableDefinition = { + id: 'tbl-1', + name: 'People', + description: null, + schema: { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 1000, + workspaceId: 'ws-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +describe('updateRow — partial merge', () => { + let mockSet: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + // db.select() → used by getRowById to fetch the existing row + const mockLimit = vi.fn().mockResolvedValue([EXISTING_ROW]) + const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockSelectWhere }) + databaseMock.db.select.mockReturnValue({ from: mockFrom }) + + // db.update() → captures what merged data gets written + const mockUpdateWhere = vi.fn().mockResolvedValue([]) + mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + databaseMock.db.update.mockReturnValue({ set: mockSet }) + }) + + it('preserves columns not included in the partial update', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Alice', age: 31 }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Alice', age: 31 } }) + ) + }) + + it('allows updating a single column without affecting others', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Bob', age: 30 }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Bob', age: 30 } }) + ) + }) + + it('allows explicitly nulling a field while preserving others', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: null }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Alice', age: null }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Alice', age: null } }) + ) + }) + + it('handles a full-row update correctly (idempotent merge)', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob', age: 25 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Bob', age: 25 }) + }) + + it('throws when the row does not exist', async () => { + const mockLimit = vi.fn().mockResolvedValue([]) + const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockSelectWhere }) + databaseMock.db.select.mockReturnValue({ from: mockFrom }) + + await expect( + updateRow( + { tableId: 'tbl-1', rowId: 'row-missing', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + ).rejects.toThrow('Row not found') + }) +}) diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index cfa1f5dbecb..a8e0bdecb71 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -1207,14 +1207,20 @@ export async function updateRow( throw new Error('Row not found') } + // Merge partial update with existing row data so callers can pass only changed fields + const mergedData = { + ...(existingRow.data as RowData), + ...data.data, + } + // Validate size - const sizeValidation = validateRowSize(data.data) + const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { throw new Error(sizeValidation.errors.join(', ')) } // Validate against schema - const schemaValidation = validateRowAgainstSchema(data.data, table.schema) + const schemaValidation = validateRowAgainstSchema(mergedData, table.schema) if (!schemaValidation.valid) { throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) } @@ -1224,7 +1230,7 @@ export async function updateRow( if (uniqueColumns.length > 0) { const uniqueValidation = await checkUniqueConstraintsDb( data.tableId, - data.data, + mergedData, table.schema, data.rowId // Exclude current row ) @@ -1237,14 +1243,14 @@ export async function updateRow( await db .update(userTableRows) - .set({ data: data.data, updatedAt: now }) + .set({ data: mergedData, updatedAt: now }) .where(eq(userTableRows.id, data.rowId)) logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`) return { id: data.rowId, - data: data.data, + data: mergedData, position: existingRow.position, createdAt: existingRow.createdAt, updatedAt: now, diff --git a/apps/sim/stores/table/store.ts b/apps/sim/stores/table/store.ts index 3b5b1d3de96..6590ba6e6b0 100644 --- a/apps/sim/stores/table/store.ts +++ b/apps/sim/stores/table/store.ts @@ -13,6 +13,73 @@ const EMPTY_STACKS: TableUndoStacks = { undo: [], redo: [] } let undoRedoInProgress = false +function patchRowIdInEntry(entry: UndoEntry, oldRowId: string, newRowId: string): UndoEntry { + const { action } = entry + switch (action.type) { + case 'update-cell': + if (action.rowId === oldRowId) { + return { ...entry, action: { ...action, rowId: newRowId } } + } + break + case 'clear-cells': { + const hasMatch = action.cells.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cells.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cells: patched } } + } + break + } + case 'update-cells': { + const hasMatch = action.cells.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cells.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cells: patched } } + } + break + } + case 'create-row': + if (action.rowId === oldRowId) { + return { ...entry, action: { ...action, rowId: newRowId } } + } + break + case 'create-rows': { + const hasMatch = action.rows.some((r) => r.rowId === oldRowId) + if (hasMatch) { + const patched = action.rows.map((r) => + r.rowId === oldRowId ? { ...r, rowId: newRowId } : r + ) + return { ...entry, action: { ...action, rows: patched } } + } + break + } + case 'delete-rows': { + const hasMatch = action.rows.some((r) => r.rowId === oldRowId) + if (hasMatch) { + const patched = action.rows.map((r) => + r.rowId === oldRowId ? { ...r, rowId: newRowId } : r + ) + return { ...entry, action: { ...action, rows: patched } } + } + break + } + case 'delete-column': { + const hasMatch = action.cellData.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cellData.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cellData: patched } } + } + break + } + } + return entry +} + /** * Run a function without recording undo entries. * Used by the hook when executing undo/redo mutations to prevent recursive recording. @@ -83,46 +150,35 @@ export const useTableUndoStore = create()( }, patchRedoRowId: (tableId: string, oldRowId: string, newRowId: string) => { - const stacks = get().stacks[tableId] - if (!stacks) return - - const patchedRedo = stacks.redo.map((entry) => { - const { action } = entry - if (action.type === 'delete-rows') { - const patchedRows = action.rows.map((r) => - r.rowId === oldRowId ? { ...r, rowId: newRowId } : r - ) - return { ...entry, action: { ...action, rows: patchedRows } } + set((state) => { + const stacks = state.stacks[tableId] + if (!stacks) return state + const patchedRedo = stacks.redo.map((entry) => + patchRowIdInEntry(entry, oldRowId, newRowId) + ) + return { + stacks: { + ...state.stacks, + [tableId]: { ...stacks, redo: patchedRedo }, + }, } - return entry }) - - set((state) => ({ - stacks: { - ...state.stacks, - [tableId]: { ...stacks, redo: patchedRedo }, - }, - })) }, patchUndoRowId: (tableId: string, oldRowId: string, newRowId: string) => { - const stacks = get().stacks[tableId] - if (!stacks) return - - const patchedUndo = stacks.undo.map((entry) => { - const { action } = entry - if (action.type === 'create-row' && action.rowId === oldRowId) { - return { ...entry, action: { ...action, rowId: newRowId } } + set((state) => { + const stacks = state.stacks[tableId] + if (!stacks) return state + const patchedUndo = stacks.undo.map((entry) => + patchRowIdInEntry(entry, oldRowId, newRowId) + ) + return { + stacks: { + ...state.stacks, + [tableId]: { ...stacks, undo: patchedUndo }, + }, } - return entry }) - - set((state) => ({ - stacks: { - ...state.stacks, - [tableId]: { ...stacks, undo: patchedUndo }, - }, - })) }, clear: (tableId: string) => { diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index fbea638f014..8ff515681b0 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -32,6 +32,17 @@ export type TableUndoAction = } | { type: 'delete-rows'; rows: DeletedRowSnapshot[] } | { type: 'create-column'; columnName: string; position: number } + | { + type: 'delete-column' + columnName: string + columnType: string + columnPosition: number + columnUnique: boolean + columnRequired: boolean + cellData: Array<{ rowId: string; value: unknown }> + previousOrder: string[] | null + previousWidth: number | null + } | { type: 'rename-column'; oldName: string; newName: string } | { type: 'update-column-type'; columnName: string; previousType: string; newType: string } | { @@ -42,6 +53,7 @@ export type TableUndoAction = newValue: boolean } | { type: 'rename-table'; tableId: string; previousName: string; newName: string } + | { type: 'reorder-columns'; previousOrder: string[]; newOrder: string[] } export interface UndoEntry { id: string
{isRenaming ? (
@@ -2796,20 +3265,47 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
) : (
- + + + - + )}
e.stopPropagation()} onPointerDown={handleResizePointerDown} + onDoubleClick={(e) => { + e.preventDefault() + e.stopPropagation() + onAutoResize(column.name) + }} />