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 1a1993aec6..cbe1f625f4 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 @@ -85,7 +85,38 @@ import { const logger = createLogger('TableView') -const EMPTY_CHECKED_ROWS = new Set() +type RowSelection = { kind: 'none' } | { kind: 'some'; ids: Set } | { kind: 'all' } + +const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } +const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } + +function rowSelectionIncludes(sel: RowSelection, id: string): boolean { + if (sel.kind === 'all') return true + if (sel.kind === 'some') return sel.ids.has(id) + return false +} + +function rowSelectionIsEmpty(sel: RowSelection): boolean { + if (sel.kind === 'none') return true + if (sel.kind === 'some') return sel.ids.size === 0 + return false +} + +function rowSelectionMaterialize(sel: RowSelection, rows: TableRowType[]): Set { + if (sel.kind === 'all') return new Set(rows.map((r) => r.id)) + if (sel.kind === 'some') return new Set(sel.ids) + return new Set() +} + +function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean { + if (rows.length === 0) return false + if (sel.kind === 'all') return true + if (sel.kind === 'none') return false + if (sel.ids.size < rows.length) return false + for (const r of rows) if (!sel.ids.has(r.id)) return false + return true +} + const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 // Wide enough to host the row-number + per-row run button side by side. @@ -143,7 +174,7 @@ export function Table({ const [expandedCell, setExpandedCell] = useState(null) const [selectionAnchor, setSelectionAnchor] = useState(null) const [selectionFocus, setSelectionFocus] = useState(null) - const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS) + const [rowSelection, setRowSelection] = useState(ROW_SELECTION_NONE) const [isColumnSelection, setIsColumnSelection] = useState(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) @@ -379,22 +410,10 @@ export function Table({ return null }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) - const isAllRowsSelected = useMemo(() => { - if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { - for (const row of rows) { - if (!checkedRows.has(row.id)) return false - } - return true - } - return ( - normalizedSelection !== null && - rows.length > 0 && - normalizedSelection.startRow === 0 && - normalizedSelection.endRow === rows.length - 1 && - normalizedSelection.startCol === 0 && - normalizedSelection.endCol === displayColumns.length - 1 - ) - }, [checkedRows, normalizedSelection, displayColumns.length, rows]) + const isAllRowsSelected = useMemo( + () => rowSelectionCoversAll(rowSelection, rows), + [rowSelection, rows] + ) const isAllRowsSelectedRef = useRef(isAllRowsSelected) isAllRowsSelectedRef.current = isAllRowsSelected @@ -408,8 +427,8 @@ export function Table({ const anchorRowIdRef = useRef(null) const focusRowIdRef = useRef(null) - const checkedRowsRef = useRef(checkedRows) - checkedRowsRef.current = checkedRows + const rowSelectionRef = useRef(rowSelection) + rowSelectionRef.current = rowSelection columnsRef.current = displayColumns schemaColumnsRef.current = columns @@ -498,12 +517,16 @@ export function Table({ return } - const checked = checkedRowsRef.current + const rowSel = rowSelectionRef.current const currentRows = rowsRef.current let snapshots: DeletedRowSnapshot[] = [] - if (checked.size > 0 && checked.has(contextRow.id)) { - snapshots = collectRowSnapshots(currentRows.filter((r) => checked.has(r.id))) + const contextRowInRows = currentRows.some((r) => r.id === contextRow.id) + + if (rowSel.kind === 'all' && contextRowInRows) { + snapshots = collectRowSnapshots(currentRows) + } else if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) { + snapshots = collectRowSnapshots(currentRows.filter((r) => rowSel.ids.has(r.id))) } else { const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) const contextRowArrayIndex = currentRows.findIndex((r) => r.id === contextRow.id) @@ -677,7 +700,7 @@ export function Table({ const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, shiftKey: boolean) => { - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null if (shiftKey && selectionAnchorRef.current) { @@ -713,25 +736,22 @@ export function Table({ ? currentRows.findIndex((r) => r.id === lastCheckboxRowRef.current) : -1 - if (lastIdx !== -1) { - const from = Math.min(lastIdx, rowIndex) - const to = Math.max(lastIdx, rowIndex) - setCheckedRows((prev) => { - const next = new Set(prev) + setRowSelection((prev) => { + const next = rowSelectionMaterialize(prev, currentRows) + if (lastIdx !== -1) { + const from = Math.min(lastIdx, rowIndex) + const to = Math.max(lastIdx, rowIndex) for (let i = from; i <= to; i++) { const r = currentRows[i] if (r) next.add(r.id) } - return next - }) - } else { - setCheckedRows((prev) => { - const next = new Set(prev) - if (next.has(targetId)) next.delete(targetId) - else next.add(targetId) - return next - }) - } + } else if (next.has(targetId)) { + next.delete(targetId) + } else { + next.add(targetId) + } + return next.size === 0 ? ROW_SELECTION_NONE : { kind: 'some', ids: next } + }) lastCheckboxRowRef.current = targetId scrollRef.current?.focus({ preventScroll: true }) }, []) @@ -739,7 +759,7 @@ export function Table({ const handleClearSelection = useCallback(() => { setSelectionAnchor(null) setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null }, []) @@ -749,7 +769,7 @@ export function Table({ if (lastRow < 0) return setEditingCell(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) lastCheckboxRowRef.current = null if (shiftKey && isColumnSelectionRef.current && selectionAnchorRef.current) { @@ -768,7 +788,7 @@ export function Table({ if (lastRow < 0) return setEditingCell(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) lastCheckboxRowRef.current = null setSelectionAnchor({ rowIndex: 0, colIndex: startColIndex }) @@ -783,7 +803,7 @@ export function Table({ const currentCols = columnsRef.current if (rws.length === 0 || currentCols.length === 0) return setEditingCell(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection(ROW_SELECTION_ALL) lastCheckboxRowRef.current = null suppressFocusScrollRef.current = true setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) @@ -875,7 +895,7 @@ export function Table({ setDragColumnName(columnName) setSelectionAnchor(null) setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) }, []) @@ -1339,7 +1359,7 @@ export function Table({ } setSelectionAnchor(null) setSelectionFocus(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null return @@ -1352,7 +1372,7 @@ export function Table({ if (rws.length > 0 && currentCols.length > 0) { suppressFocusScrollRef.current = true setEditingCell(null) - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) lastCheckboxRowRef.current = null setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) setSelectionFocus({ @@ -1370,7 +1390,7 @@ export function Table({ const lastRow = rowsRef.current.length - 1 if (lastRow < 0) return e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) lastCheckboxRowRef.current = null setSelectionAnchor({ rowIndex: 0, colIndex: a.colIndex }) setSelectionFocus({ rowIndex: lastRow, colIndex: a.colIndex }) @@ -1384,7 +1404,7 @@ export function Table({ const currentCols = columnsRef.current if (currentCols.length === 0) return e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) lastCheckboxRowRef.current = null setIsColumnSelection(false) setSelectionAnchor({ rowIndex: a.rowIndex, colIndex: 0 }) @@ -1392,17 +1412,20 @@ export function Table({ return } - if ((e.key === 'Delete' || e.key === 'Backspace') && checkedRowsRef.current.size > 0) { + if ( + (e.key === 'Delete' || e.key === 'Backspace') && + !rowSelectionIsEmpty(rowSelectionRef.current) + ) { if (editingCellRef.current) return if (!canEditRef.current) return e.preventDefault() - const checked = checkedRowsRef.current + const rowSel = rowSelectionRef.current const currentRows = rowsRef.current const currentCols = columnsRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] const batchUpdates: Array<{ rowId: string; data: Record }> = [] for (const row of currentRows) { - if (!checked.has(row.id)) continue + if (!rowSelectionIncludes(rowSel, row.id)) continue const updates: Record = {} const previousData: Record = {} for (const col of currentCols) { @@ -1481,7 +1504,7 @@ export function Table({ if (e.key === 'Tab') { e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) @@ -1491,7 +1514,7 @@ export function Table({ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() - setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE)) setIsColumnSelection(false) lastCheckboxRowRef.current = null const focus = selectionFocusRef.current ?? anchor @@ -1669,15 +1692,15 @@ export function Table({ if (tag === 'INPUT' || tag === 'TEXTAREA') return if (editingCellRef.current) return - const checked = checkedRowsRef.current + const rowSel = rowSelectionRef.current const cols = columnsRef.current const currentRows = rowsRef.current - if (checked.size > 0) { + if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() const lines: string[] = [] for (const row of currentRows) { - if (!checked.has(row.id)) continue + if (!rowSelectionIncludes(rowSel, row.id)) continue const cells: string[] = cols.map((col) => { const value: unknown = row.data[col.name] if (value === null || value === undefined) return '' @@ -1720,17 +1743,17 @@ export function Table({ if (editingCellRef.current) return if (!canEditRef.current) return - const checked = checkedRowsRef.current + const rowSel = rowSelectionRef.current const cols = columnsRef.current const currentRows = rowsRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] const batchUpdates: Array<{ rowId: string; data: Record }> = [] - if (checked.size > 0) { + if (!rowSelectionIsEmpty(rowSel)) { e.preventDefault() const lines: string[] = [] for (const row of currentRows) { - if (!checked.has(row.id)) continue + if (!rowSelectionIncludes(rowSel, row.id)) continue const cells: string[] = cols.map((col) => { const value: unknown = row.data[col.name] if (value === null || value === undefined) return '' @@ -2425,10 +2448,14 @@ export function Table({ const contextRow = contextMenu.isOpen ? contextMenu.row : null if (!contextRow) return 1 - if (checkedRows.size > 0 && checkedRows.has(contextRow.id)) { + if (rowSelection.kind === 'all') { + return rows.some((r) => r.id === contextRow.id) ? Math.max(rows.length, 1) : 1 + } + + if (rowSelection.kind === 'some' && rowSelection.ids.has(contextRow.id)) { let count = 0 for (const row of rows) { - if (checkedRows.has(row.id)) count++ + if (rowSelection.ids.has(row.id)) count++ } return Math.max(count, 1) } @@ -2442,7 +2469,7 @@ export function Table({ const start = Math.max(0, sel.startRow) const end = Math.min(rows.length - 1, sel.endRow) return Math.max(end - start + 1, 1) - }, [contextMenu.isOpen, contextMenu.row, checkedRows, normalizedSelection, rows]) + }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows]) const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null @@ -2756,7 +2783,7 @@ export function Table({ onContextMenu={handleRowContextMenu} onCellMouseDown={handleCellMouseDown} onCellMouseEnter={handleCellMouseEnter} - isRowChecked={checkedRows.has(row.id)} + isRowChecked={rowSelectionIncludes(rowSelection, row.id)} onRowToggle={handleRowToggle} runningCount={runningByRowId.get(row.id) ?? 0} hasWorkflowColumns={hasWorkflowColumns} @@ -3012,7 +3039,7 @@ interface DataRowProps { workflowNameById: Record } -function rowSelectionChanged( +function cellRangeRowChanged( rowIndex: number, colCount: number, prev: NormalizedSelection | null, @@ -3075,7 +3102,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { return false } - return !rowSelectionChanged( + return !cellRangeRowChanged( prev.rowIndex, prev.columns.length, prev.normalizedSelection, @@ -3109,13 +3136,7 @@ const DataRow = React.memo(function DataRow({ }: DataRowProps) { const sel = normalizedSelection const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) - const isRowSelectedByRange = - sel !== null && - rowIndex >= sel.startRow && - rowIndex <= sel.endRow && - sel.startCol === 0 && - sel.endCol === columns.length - 1 - const isRowSelected = isRowChecked || isRowSelectedByRange + const isRowSelected = isRowChecked return ( onContextMenu(e, row)}>