Skip to content

Commit 1814105

Browse files
authored
refactor(tables): row selection as discriminated union (#4466)
* fix(tables): decouple master checkbox from cell-range, add allRowsSelected flag Master checkbox detached from gutter selection state when rows or columns changed after Cmd+A: the predicate matched normalizedSelection bounds exactly (endRow === rows.length-1, endCol === displayColumns.length-1), so any post-selection growth flipped it false while the cell-range overlay still painted every row checked. Replace the structural two-branch predicate with an explicit allRowsSelected flag plus a uniform set-membership check. handleSelectAllRows sets the flag in O(1); handleRowToggle materializes checkedRows when toggling out of "all" mode. Bulk-op read sites (delete, copy, cut, selectedRowCount) honor the flag. Decouple gutter checkbox from cell-range drag: dragging cells no longer fills gutter checkboxes — they reflect explicit row-selection intent only, matching Sheets/Airtable. Cell-range overlay still paints cells. * refactor(tables): row selection as discriminated union Collapse `checkedRows: Set<string>` + `allRowsSelected: boolean` into a single `RowSelection = { kind: 'none' | 'some' | 'all' }`. Impossible states (all + non-empty Set) become unrepresentable; predicates like `rowSelectionIncludes` and `rowSelectionIsEmpty` replace ad-hoc checks at every read site. * fix(tables): clear row selection after context-menu delete handleContextMenuDelete dispatched the delete but left rowSelection at its prior 'all' or 'some' state. After rows clear and a new row arrives (realtime, undo, append), rowSelectionIncludes returned true for it, rendering it checked and flipping the master checkbox back on. * chore(tables): address review nits on row selection refactor - guard selectedRowCount 'all' branch on contextRow membership in rows - restore blank line between row-selection helpers and constants * chore(tables): rename rowSelectionChanged to cellRangeRowChanged The helper compares NormalizedSelection (cell-range) state for a given row, not RowSelection. The old name collided with the new row-selection discriminated union and read ambiguously. * fix(tables): guard context-menu delete on stale rows, preserve selection on cancel - Guard the kind='all' branch on contextRow membership in currentRows (matches the same fix applied to selectedRowCount), so a context menu on a stale row no longer deletes the entire table. - Drop the eager rowSelection clear at modal-open time. The modal's onSuccess already calls handleClearSelection after the mutation resolves, so the post-delete invariant still holds; if the user cancels, the selection is now preserved.
1 parent 80eb5b9 commit 1814105

1 file changed

Lines changed: 93 additions & 72 deletions

File tree

  • apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx

Lines changed: 93 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,38 @@ import {
8585

8686
const logger = createLogger('TableView')
8787

88-
const EMPTY_CHECKED_ROWS = new Set<string>()
88+
type RowSelection = { kind: 'none' } | { kind: 'some'; ids: Set<string> } | { kind: 'all' }
89+
90+
const ROW_SELECTION_NONE: RowSelection = { kind: 'none' }
91+
const ROW_SELECTION_ALL: RowSelection = { kind: 'all' }
92+
93+
function rowSelectionIncludes(sel: RowSelection, id: string): boolean {
94+
if (sel.kind === 'all') return true
95+
if (sel.kind === 'some') return sel.ids.has(id)
96+
return false
97+
}
98+
99+
function rowSelectionIsEmpty(sel: RowSelection): boolean {
100+
if (sel.kind === 'none') return true
101+
if (sel.kind === 'some') return sel.ids.size === 0
102+
return false
103+
}
104+
105+
function rowSelectionMaterialize(sel: RowSelection, rows: TableRowType[]): Set<string> {
106+
if (sel.kind === 'all') return new Set(rows.map((r) => r.id))
107+
if (sel.kind === 'some') return new Set(sel.ids)
108+
return new Set<string>()
109+
}
110+
111+
function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean {
112+
if (rows.length === 0) return false
113+
if (sel.kind === 'all') return true
114+
if (sel.kind === 'none') return false
115+
if (sel.ids.size < rows.length) return false
116+
for (const r of rows) if (!sel.ids.has(r.id)) return false
117+
return true
118+
}
119+
89120
const COL_WIDTH_MIN = 80
90121
const COL_WIDTH_AUTO_FIT_MAX = 1000
91122
// Wide enough to host the row-number + per-row run button side by side.
@@ -143,7 +174,7 @@ export function Table({
143174
const [expandedCell, setExpandedCell] = useState<EditingCell | null>(null)
144175
const [selectionAnchor, setSelectionAnchor] = useState<CellCoord | null>(null)
145176
const [selectionFocus, setSelectionFocus] = useState<CellCoord | null>(null)
146-
const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS)
177+
const [rowSelection, setRowSelection] = useState<RowSelection>(ROW_SELECTION_NONE)
147178
const [isColumnSelection, setIsColumnSelection] = useState(false)
148179
const lastCheckboxRowRef = useRef<string | null>(null)
149180
const isColumnSelectionRef = useRef(false)
@@ -379,22 +410,10 @@ export function Table({
379410
return null
380411
}, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths])
381412

382-
const isAllRowsSelected = useMemo(() => {
383-
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
384-
for (const row of rows) {
385-
if (!checkedRows.has(row.id)) return false
386-
}
387-
return true
388-
}
389-
return (
390-
normalizedSelection !== null &&
391-
rows.length > 0 &&
392-
normalizedSelection.startRow === 0 &&
393-
normalizedSelection.endRow === rows.length - 1 &&
394-
normalizedSelection.startCol === 0 &&
395-
normalizedSelection.endCol === displayColumns.length - 1
396-
)
397-
}, [checkedRows, normalizedSelection, displayColumns.length, rows])
413+
const isAllRowsSelected = useMemo(
414+
() => rowSelectionCoversAll(rowSelection, rows),
415+
[rowSelection, rows]
416+
)
398417

399418
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
400419
isAllRowsSelectedRef.current = isAllRowsSelected
@@ -408,8 +427,8 @@ export function Table({
408427
const anchorRowIdRef = useRef<string | null>(null)
409428
const focusRowIdRef = useRef<string | null>(null)
410429

411-
const checkedRowsRef = useRef(checkedRows)
412-
checkedRowsRef.current = checkedRows
430+
const rowSelectionRef = useRef(rowSelection)
431+
rowSelectionRef.current = rowSelection
413432

414433
columnsRef.current = displayColumns
415434
schemaColumnsRef.current = columns
@@ -498,12 +517,16 @@ export function Table({
498517
return
499518
}
500519

501-
const checked = checkedRowsRef.current
520+
const rowSel = rowSelectionRef.current
502521
const currentRows = rowsRef.current
503522
let snapshots: DeletedRowSnapshot[] = []
504523

505-
if (checked.size > 0 && checked.has(contextRow.id)) {
506-
snapshots = collectRowSnapshots(currentRows.filter((r) => checked.has(r.id)))
524+
const contextRowInRows = currentRows.some((r) => r.id === contextRow.id)
525+
526+
if (rowSel.kind === 'all' && contextRowInRows) {
527+
snapshots = collectRowSnapshots(currentRows)
528+
} else if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) {
529+
snapshots = collectRowSnapshots(currentRows.filter((r) => rowSel.ids.has(r.id)))
507530
} else {
508531
const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current)
509532
const contextRowArrayIndex = currentRows.findIndex((r) => r.id === contextRow.id)
@@ -677,7 +700,7 @@ export function Table({
677700

678701
const handleCellMouseDown = useCallback(
679702
(rowIndex: number, colIndex: number, shiftKey: boolean) => {
680-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
703+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
681704
setIsColumnSelection(false)
682705
lastCheckboxRowRef.current = null
683706
if (shiftKey && selectionAnchorRef.current) {
@@ -713,33 +736,30 @@ export function Table({
713736
? currentRows.findIndex((r) => r.id === lastCheckboxRowRef.current)
714737
: -1
715738

716-
if (lastIdx !== -1) {
717-
const from = Math.min(lastIdx, rowIndex)
718-
const to = Math.max(lastIdx, rowIndex)
719-
setCheckedRows((prev) => {
720-
const next = new Set(prev)
739+
setRowSelection((prev) => {
740+
const next = rowSelectionMaterialize(prev, currentRows)
741+
if (lastIdx !== -1) {
742+
const from = Math.min(lastIdx, rowIndex)
743+
const to = Math.max(lastIdx, rowIndex)
721744
for (let i = from; i <= to; i++) {
722745
const r = currentRows[i]
723746
if (r) next.add(r.id)
724747
}
725-
return next
726-
})
727-
} else {
728-
setCheckedRows((prev) => {
729-
const next = new Set(prev)
730-
if (next.has(targetId)) next.delete(targetId)
731-
else next.add(targetId)
732-
return next
733-
})
734-
}
748+
} else if (next.has(targetId)) {
749+
next.delete(targetId)
750+
} else {
751+
next.add(targetId)
752+
}
753+
return next.size === 0 ? ROW_SELECTION_NONE : { kind: 'some', ids: next }
754+
})
735755
lastCheckboxRowRef.current = targetId
736756
scrollRef.current?.focus({ preventScroll: true })
737757
}, [])
738758

739759
const handleClearSelection = useCallback(() => {
740760
setSelectionAnchor(null)
741761
setSelectionFocus(null)
742-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
762+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
743763
setIsColumnSelection(false)
744764
lastCheckboxRowRef.current = null
745765
}, [])
@@ -749,7 +769,7 @@ export function Table({
749769
if (lastRow < 0) return
750770

751771
setEditingCell(null)
752-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
772+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
753773
lastCheckboxRowRef.current = null
754774

755775
if (shiftKey && isColumnSelectionRef.current && selectionAnchorRef.current) {
@@ -768,7 +788,7 @@ export function Table({
768788
if (lastRow < 0) return
769789

770790
setEditingCell(null)
771-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
791+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
772792
lastCheckboxRowRef.current = null
773793

774794
setSelectionAnchor({ rowIndex: 0, colIndex: startColIndex })
@@ -783,7 +803,7 @@ export function Table({
783803
const currentCols = columnsRef.current
784804
if (rws.length === 0 || currentCols.length === 0) return
785805
setEditingCell(null)
786-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
806+
setRowSelection(ROW_SELECTION_ALL)
787807
lastCheckboxRowRef.current = null
788808
suppressFocusScrollRef.current = true
789809
setSelectionAnchor({ rowIndex: 0, colIndex: 0 })
@@ -875,7 +895,7 @@ export function Table({
875895
setDragColumnName(columnName)
876896
setSelectionAnchor(null)
877897
setSelectionFocus(null)
878-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
898+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
879899
setIsColumnSelection(false)
880900
}, [])
881901

@@ -1339,7 +1359,7 @@ export function Table({
13391359
}
13401360
setSelectionAnchor(null)
13411361
setSelectionFocus(null)
1342-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1362+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13431363
setIsColumnSelection(false)
13441364
lastCheckboxRowRef.current = null
13451365
return
@@ -1352,7 +1372,7 @@ export function Table({
13521372
if (rws.length > 0 && currentCols.length > 0) {
13531373
suppressFocusScrollRef.current = true
13541374
setEditingCell(null)
1355-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1375+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13561376
lastCheckboxRowRef.current = null
13571377
setSelectionAnchor({ rowIndex: 0, colIndex: 0 })
13581378
setSelectionFocus({
@@ -1370,7 +1390,7 @@ export function Table({
13701390
const lastRow = rowsRef.current.length - 1
13711391
if (lastRow < 0) return
13721392
e.preventDefault()
1373-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1393+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13741394
lastCheckboxRowRef.current = null
13751395
setSelectionAnchor({ rowIndex: 0, colIndex: a.colIndex })
13761396
setSelectionFocus({ rowIndex: lastRow, colIndex: a.colIndex })
@@ -1384,25 +1404,28 @@ export function Table({
13841404
const currentCols = columnsRef.current
13851405
if (currentCols.length === 0) return
13861406
e.preventDefault()
1387-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1407+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13881408
lastCheckboxRowRef.current = null
13891409
setIsColumnSelection(false)
13901410
setSelectionAnchor({ rowIndex: a.rowIndex, colIndex: 0 })
13911411
setSelectionFocus({ rowIndex: a.rowIndex, colIndex: currentCols.length - 1 })
13921412
return
13931413
}
13941414

1395-
if ((e.key === 'Delete' || e.key === 'Backspace') && checkedRowsRef.current.size > 0) {
1415+
if (
1416+
(e.key === 'Delete' || e.key === 'Backspace') &&
1417+
!rowSelectionIsEmpty(rowSelectionRef.current)
1418+
) {
13961419
if (editingCellRef.current) return
13971420
if (!canEditRef.current) return
13981421
e.preventDefault()
1399-
const checked = checkedRowsRef.current
1422+
const rowSel = rowSelectionRef.current
14001423
const currentRows = rowsRef.current
14011424
const currentCols = columnsRef.current
14021425
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
14031426
const batchUpdates: Array<{ rowId: string; data: Record<string, unknown> }> = []
14041427
for (const row of currentRows) {
1405-
if (!checked.has(row.id)) continue
1428+
if (!rowSelectionIncludes(rowSel, row.id)) continue
14061429
const updates: Record<string, unknown> = {}
14071430
const previousData: Record<string, unknown> = {}
14081431
for (const col of currentCols) {
@@ -1481,7 +1504,7 @@ export function Table({
14811504

14821505
if (e.key === 'Tab') {
14831506
e.preventDefault()
1484-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1507+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
14851508
setIsColumnSelection(false)
14861509
lastCheckboxRowRef.current = null
14871510
setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1))
@@ -1491,7 +1514,7 @@ export function Table({
14911514

14921515
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
14931516
e.preventDefault()
1494-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1517+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
14951518
setIsColumnSelection(false)
14961519
lastCheckboxRowRef.current = null
14971520
const focus = selectionFocusRef.current ?? anchor
@@ -1669,15 +1692,15 @@ export function Table({
16691692
if (tag === 'INPUT' || tag === 'TEXTAREA') return
16701693
if (editingCellRef.current) return
16711694

1672-
const checked = checkedRowsRef.current
1695+
const rowSel = rowSelectionRef.current
16731696
const cols = columnsRef.current
16741697
const currentRows = rowsRef.current
16751698

1676-
if (checked.size > 0) {
1699+
if (!rowSelectionIsEmpty(rowSel)) {
16771700
e.preventDefault()
16781701
const lines: string[] = []
16791702
for (const row of currentRows) {
1680-
if (!checked.has(row.id)) continue
1703+
if (!rowSelectionIncludes(rowSel, row.id)) continue
16811704
const cells: string[] = cols.map((col) => {
16821705
const value: unknown = row.data[col.name]
16831706
if (value === null || value === undefined) return ''
@@ -1720,17 +1743,17 @@ export function Table({
17201743
if (editingCellRef.current) return
17211744
if (!canEditRef.current) return
17221745

1723-
const checked = checkedRowsRef.current
1746+
const rowSel = rowSelectionRef.current
17241747
const cols = columnsRef.current
17251748
const currentRows = rowsRef.current
17261749
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
17271750
const batchUpdates: Array<{ rowId: string; data: Record<string, unknown> }> = []
17281751

1729-
if (checked.size > 0) {
1752+
if (!rowSelectionIsEmpty(rowSel)) {
17301753
e.preventDefault()
17311754
const lines: string[] = []
17321755
for (const row of currentRows) {
1733-
if (!checked.has(row.id)) continue
1756+
if (!rowSelectionIncludes(rowSel, row.id)) continue
17341757
const cells: string[] = cols.map((col) => {
17351758
const value: unknown = row.data[col.name]
17361759
if (value === null || value === undefined) return ''
@@ -2425,10 +2448,14 @@ export function Table({
24252448
const contextRow = contextMenu.isOpen ? contextMenu.row : null
24262449
if (!contextRow) return 1
24272450

2428-
if (checkedRows.size > 0 && checkedRows.has(contextRow.id)) {
2451+
if (rowSelection.kind === 'all') {
2452+
return rows.some((r) => r.id === contextRow.id) ? Math.max(rows.length, 1) : 1
2453+
}
2454+
2455+
if (rowSelection.kind === 'some' && rowSelection.ids.has(contextRow.id)) {
24292456
let count = 0
24302457
for (const row of rows) {
2431-
if (checkedRows.has(row.id)) count++
2458+
if (rowSelection.ids.has(row.id)) count++
24322459
}
24332460
return Math.max(count, 1)
24342461
}
@@ -2442,7 +2469,7 @@ export function Table({
24422469
const start = Math.max(0, sel.startRow)
24432470
const end = Math.min(rows.length - 1, sel.endRow)
24442471
return Math.max(end - start + 1, 1)
2445-
}, [contextMenu.isOpen, contextMenu.row, checkedRows, normalizedSelection, rows])
2472+
}, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows])
24462473

24472474
const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null
24482475

@@ -2756,7 +2783,7 @@ export function Table({
27562783
onContextMenu={handleRowContextMenu}
27572784
onCellMouseDown={handleCellMouseDown}
27582785
onCellMouseEnter={handleCellMouseEnter}
2759-
isRowChecked={checkedRows.has(row.id)}
2786+
isRowChecked={rowSelectionIncludes(rowSelection, row.id)}
27602787
onRowToggle={handleRowToggle}
27612788
runningCount={runningByRowId.get(row.id) ?? 0}
27622789
hasWorkflowColumns={hasWorkflowColumns}
@@ -3012,7 +3039,7 @@ interface DataRowProps {
30123039
workflowNameById: Record<string, string>
30133040
}
30143041

3015-
function rowSelectionChanged(
3042+
function cellRangeRowChanged(
30163043
rowIndex: number,
30173044
colCount: number,
30183045
prev: NormalizedSelection | null,
@@ -3075,7 +3102,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
30753102
return false
30763103
}
30773104

3078-
return !rowSelectionChanged(
3105+
return !cellRangeRowChanged(
30793106
prev.rowIndex,
30803107
prev.columns.length,
30813108
prev.normalizedSelection,
@@ -3109,13 +3136,7 @@ const DataRow = React.memo(function DataRow({
31093136
}: DataRowProps) {
31103137
const sel = normalizedSelection
31113138
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
3112-
const isRowSelectedByRange =
3113-
sel !== null &&
3114-
rowIndex >= sel.startRow &&
3115-
rowIndex <= sel.endRow &&
3116-
sel.startCol === 0 &&
3117-
sel.endCol === columns.length - 1
3118-
const isRowSelected = isRowChecked || isRowSelectedByRange
3139+
const isRowSelected = isRowChecked
31193140

31203141
return (
31213142
<tr onContextMenu={(e) => onContextMenu(e, row)}>

0 commit comments

Comments
 (0)