Skip to content

Commit d1c0eed

Browse files
committed
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.
1 parent 22b555e commit d1c0eed

1 file changed

Lines changed: 80 additions & 84 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: 80 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,37 @@ 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+
}
89119
const COL_WIDTH_MIN = 80
90120
const COL_WIDTH_AUTO_FIT_MAX = 1000
91121
// Wide enough to host the row-number + per-row run button side by side.
@@ -143,8 +173,7 @@ export function Table({
143173
const [expandedCell, setExpandedCell] = useState<EditingCell | null>(null)
144174
const [selectionAnchor, setSelectionAnchor] = useState<CellCoord | null>(null)
145175
const [selectionFocus, setSelectionFocus] = useState<CellCoord | null>(null)
146-
const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS)
147-
const [allRowsSelected, setAllRowsSelected] = useState(false)
176+
const [rowSelection, setRowSelection] = useState<RowSelection>(ROW_SELECTION_NONE)
148177
const [isColumnSelection, setIsColumnSelection] = useState(false)
149178
const lastCheckboxRowRef = useRef<string | null>(null)
150179
const isColumnSelectionRef = useRef(false)
@@ -380,15 +409,10 @@ export function Table({
380409
return null
381410
}, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths])
382411

383-
const isAllRowsSelected = useMemo(() => {
384-
if (rows.length === 0) return false
385-
if (allRowsSelected) return true
386-
if (checkedRows.size < rows.length) return false
387-
for (let i = 0; i < rows.length; i++) {
388-
if (!checkedRows.has(rows[i].id)) return false
389-
}
390-
return true
391-
}, [allRowsSelected, checkedRows, rows])
412+
const isAllRowsSelected = useMemo(
413+
() => rowSelectionCoversAll(rowSelection, rows),
414+
[rowSelection, rows]
415+
)
392416

393417
const isAllRowsSelectedRef = useRef(isAllRowsSelected)
394418
isAllRowsSelectedRef.current = isAllRowsSelected
@@ -402,11 +426,8 @@ export function Table({
402426
const anchorRowIdRef = useRef<string | null>(null)
403427
const focusRowIdRef = useRef<string | null>(null)
404428

405-
const checkedRowsRef = useRef(checkedRows)
406-
checkedRowsRef.current = checkedRows
407-
408-
const allRowsSelectedRef = useRef(allRowsSelected)
409-
allRowsSelectedRef.current = allRowsSelected
429+
const rowSelectionRef = useRef(rowSelection)
430+
rowSelectionRef.current = rowSelection
410431

411432
columnsRef.current = displayColumns
412433
schemaColumnsRef.current = columns
@@ -495,15 +516,14 @@ export function Table({
495516
return
496517
}
497518

498-
const checked = checkedRowsRef.current
499-
const allChecked = allRowsSelectedRef.current
519+
const rowSel = rowSelectionRef.current
500520
const currentRows = rowsRef.current
501521
let snapshots: DeletedRowSnapshot[] = []
502522

503-
if (allChecked) {
523+
if (rowSel.kind === 'all') {
504524
snapshots = collectRowSnapshots(currentRows)
505-
} else if (checked.size > 0 && checked.has(contextRow.id)) {
506-
snapshots = collectRowSnapshots(currentRows.filter((r) => checked.has(r.id)))
525+
} else if (rowSel.kind === 'some' && rowSel.ids.has(contextRow.id)) {
526+
snapshots = collectRowSnapshots(currentRows.filter((r) => rowSel.ids.has(r.id)))
507527
} else {
508528
const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current)
509529
const contextRowArrayIndex = currentRows.findIndex((r) => r.id === contextRow.id)
@@ -677,8 +697,7 @@ export function Table({
677697

678698
const handleCellMouseDown = useCallback(
679699
(rowIndex: number, colIndex: number, shiftKey: boolean) => {
680-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
681-
setAllRowsSelected(false)
700+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
682701
setIsColumnSelection(false)
683702
lastCheckboxRowRef.current = null
684703
if (shiftKey && selectionAnchorRef.current) {
@@ -714,40 +733,30 @@ export function Table({
714733
? currentRows.findIndex((r) => r.id === lastCheckboxRowRef.current)
715734
: -1
716735

717-
const wasAllSelected = allRowsSelectedRef.current
718-
if (wasAllSelected) {
719-
allRowsSelectedRef.current = false
720-
setAllRowsSelected(false)
721-
}
722-
723-
if (lastIdx !== -1) {
724-
const from = Math.min(lastIdx, rowIndex)
725-
const to = Math.max(lastIdx, rowIndex)
726-
setCheckedRows((prev) => {
727-
const next = wasAllSelected ? new Set(currentRows.map((r) => r.id)) : new Set(prev)
736+
setRowSelection((prev) => {
737+
const next = rowSelectionMaterialize(prev, currentRows)
738+
if (lastIdx !== -1) {
739+
const from = Math.min(lastIdx, rowIndex)
740+
const to = Math.max(lastIdx, rowIndex)
728741
for (let i = from; i <= to; i++) {
729742
const r = currentRows[i]
730743
if (r) next.add(r.id)
731744
}
732-
return next
733-
})
734-
} else {
735-
setCheckedRows((prev) => {
736-
const next = wasAllSelected ? new Set(currentRows.map((r) => r.id)) : new Set(prev)
737-
if (next.has(targetId)) next.delete(targetId)
738-
else next.add(targetId)
739-
return next
740-
})
741-
}
745+
} else if (next.has(targetId)) {
746+
next.delete(targetId)
747+
} else {
748+
next.add(targetId)
749+
}
750+
return next.size === 0 ? ROW_SELECTION_NONE : { kind: 'some', ids: next }
751+
})
742752
lastCheckboxRowRef.current = targetId
743753
scrollRef.current?.focus({ preventScroll: true })
744754
}, [])
745755

746756
const handleClearSelection = useCallback(() => {
747757
setSelectionAnchor(null)
748758
setSelectionFocus(null)
749-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
750-
setAllRowsSelected(false)
759+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
751760
setIsColumnSelection(false)
752761
lastCheckboxRowRef.current = null
753762
}, [])
@@ -757,8 +766,7 @@ export function Table({
757766
if (lastRow < 0) return
758767

759768
setEditingCell(null)
760-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
761-
setAllRowsSelected(false)
769+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
762770
lastCheckboxRowRef.current = null
763771

764772
if (shiftKey && isColumnSelectionRef.current && selectionAnchorRef.current) {
@@ -777,8 +785,7 @@ export function Table({
777785
if (lastRow < 0) return
778786

779787
setEditingCell(null)
780-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
781-
setAllRowsSelected(false)
788+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
782789
lastCheckboxRowRef.current = null
783790

784791
setSelectionAnchor({ rowIndex: 0, colIndex: startColIndex })
@@ -793,8 +800,7 @@ export function Table({
793800
const currentCols = columnsRef.current
794801
if (rws.length === 0 || currentCols.length === 0) return
795802
setEditingCell(null)
796-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
797-
setAllRowsSelected(true)
803+
setRowSelection(ROW_SELECTION_ALL)
798804
lastCheckboxRowRef.current = null
799805
suppressFocusScrollRef.current = true
800806
setSelectionAnchor({ rowIndex: 0, colIndex: 0 })
@@ -886,8 +892,7 @@ export function Table({
886892
setDragColumnName(columnName)
887893
setSelectionAnchor(null)
888894
setSelectionFocus(null)
889-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
890-
setAllRowsSelected(false)
895+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
891896
setIsColumnSelection(false)
892897
}, [])
893898

@@ -1351,8 +1356,7 @@ export function Table({
13511356
}
13521357
setSelectionAnchor(null)
13531358
setSelectionFocus(null)
1354-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1355-
setAllRowsSelected(false)
1359+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13561360
setIsColumnSelection(false)
13571361
lastCheckboxRowRef.current = null
13581362
return
@@ -1365,8 +1369,7 @@ export function Table({
13651369
if (rws.length > 0 && currentCols.length > 0) {
13661370
suppressFocusScrollRef.current = true
13671371
setEditingCell(null)
1368-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1369-
setAllRowsSelected(false)
1372+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13701373
lastCheckboxRowRef.current = null
13711374
setSelectionAnchor({ rowIndex: 0, colIndex: 0 })
13721375
setSelectionFocus({
@@ -1384,8 +1387,7 @@ export function Table({
13841387
const lastRow = rowsRef.current.length - 1
13851388
if (lastRow < 0) return
13861389
e.preventDefault()
1387-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1388-
setAllRowsSelected(false)
1390+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
13891391
lastCheckboxRowRef.current = null
13901392
setSelectionAnchor({ rowIndex: 0, colIndex: a.colIndex })
13911393
setSelectionFocus({ rowIndex: lastRow, colIndex: a.colIndex })
@@ -1399,8 +1401,7 @@ export function Table({
13991401
const currentCols = columnsRef.current
14001402
if (currentCols.length === 0) return
14011403
e.preventDefault()
1402-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1403-
setAllRowsSelected(false)
1404+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
14041405
lastCheckboxRowRef.current = null
14051406
setIsColumnSelection(false)
14061407
setSelectionAnchor({ rowIndex: a.rowIndex, colIndex: 0 })
@@ -1410,19 +1411,18 @@ export function Table({
14101411

14111412
if (
14121413
(e.key === 'Delete' || e.key === 'Backspace') &&
1413-
(checkedRowsRef.current.size > 0 || allRowsSelectedRef.current)
1414+
!rowSelectionIsEmpty(rowSelectionRef.current)
14141415
) {
14151416
if (editingCellRef.current) return
14161417
if (!canEditRef.current) return
14171418
e.preventDefault()
1418-
const checked = checkedRowsRef.current
1419-
const allChecked = allRowsSelectedRef.current
1419+
const rowSel = rowSelectionRef.current
14201420
const currentRows = rowsRef.current
14211421
const currentCols = columnsRef.current
14221422
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
14231423
const batchUpdates: Array<{ rowId: string; data: Record<string, unknown> }> = []
14241424
for (const row of currentRows) {
1425-
if (!allChecked && !checked.has(row.id)) continue
1425+
if (!rowSelectionIncludes(rowSel, row.id)) continue
14261426
const updates: Record<string, unknown> = {}
14271427
const previousData: Record<string, unknown> = {}
14281428
for (const col of currentCols) {
@@ -1501,8 +1501,7 @@ export function Table({
15011501

15021502
if (e.key === 'Tab') {
15031503
e.preventDefault()
1504-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1505-
setAllRowsSelected(false)
1504+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
15061505
setIsColumnSelection(false)
15071506
lastCheckboxRowRef.current = null
15081507
setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1))
@@ -1512,8 +1511,7 @@ export function Table({
15121511

15131512
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
15141513
e.preventDefault()
1515-
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
1516-
setAllRowsSelected(false)
1514+
setRowSelection((prev) => (prev.kind === 'none' ? prev : ROW_SELECTION_NONE))
15171515
setIsColumnSelection(false)
15181516
lastCheckboxRowRef.current = null
15191517
const focus = selectionFocusRef.current ?? anchor
@@ -1691,16 +1689,15 @@ export function Table({
16911689
if (tag === 'INPUT' || tag === 'TEXTAREA') return
16921690
if (editingCellRef.current) return
16931691

1694-
const checked = checkedRowsRef.current
1695-
const allChecked = allRowsSelectedRef.current
1692+
const rowSel = rowSelectionRef.current
16961693
const cols = columnsRef.current
16971694
const currentRows = rowsRef.current
16981695

1699-
if (allChecked || checked.size > 0) {
1696+
if (!rowSelectionIsEmpty(rowSel)) {
17001697
e.preventDefault()
17011698
const lines: string[] = []
17021699
for (const row of currentRows) {
1703-
if (!allChecked && !checked.has(row.id)) continue
1700+
if (!rowSelectionIncludes(rowSel, row.id)) continue
17041701
const cells: string[] = cols.map((col) => {
17051702
const value: unknown = row.data[col.name]
17061703
if (value === null || value === undefined) return ''
@@ -1743,18 +1740,17 @@ export function Table({
17431740
if (editingCellRef.current) return
17441741
if (!canEditRef.current) return
17451742

1746-
const checked = checkedRowsRef.current
1747-
const allChecked = allRowsSelectedRef.current
1743+
const rowSel = rowSelectionRef.current
17481744
const cols = columnsRef.current
17491745
const currentRows = rowsRef.current
17501746
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
17511747
const batchUpdates: Array<{ rowId: string; data: Record<string, unknown> }> = []
17521748

1753-
if (allChecked || checked.size > 0) {
1749+
if (!rowSelectionIsEmpty(rowSel)) {
17541750
e.preventDefault()
17551751
const lines: string[] = []
17561752
for (const row of currentRows) {
1757-
if (!allChecked && !checked.has(row.id)) continue
1753+
if (!rowSelectionIncludes(rowSel, row.id)) continue
17581754
const cells: string[] = cols.map((col) => {
17591755
const value: unknown = row.data[col.name]
17601756
if (value === null || value === undefined) return ''
@@ -2449,12 +2445,12 @@ export function Table({
24492445
const contextRow = contextMenu.isOpen ? contextMenu.row : null
24502446
if (!contextRow) return 1
24512447

2452-
if (allRowsSelected) return Math.max(rows.length, 1)
2448+
if (rowSelection.kind === 'all') return Math.max(rows.length, 1)
24532449

2454-
if (checkedRows.size > 0 && checkedRows.has(contextRow.id)) {
2450+
if (rowSelection.kind === 'some' && rowSelection.ids.has(contextRow.id)) {
24552451
let count = 0
24562452
for (const row of rows) {
2457-
if (checkedRows.has(row.id)) count++
2453+
if (rowSelection.ids.has(row.id)) count++
24582454
}
24592455
return Math.max(count, 1)
24602456
}
@@ -2468,7 +2464,7 @@ export function Table({
24682464
const start = Math.max(0, sel.startRow)
24692465
const end = Math.min(rows.length - 1, sel.endRow)
24702466
return Math.max(end - start + 1, 1)
2471-
}, [contextMenu.isOpen, contextMenu.row, allRowsSelected, checkedRows, normalizedSelection, rows])
2467+
}, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows])
24722468

24732469
const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null
24742470

@@ -2782,7 +2778,7 @@ export function Table({
27822778
onContextMenu={handleRowContextMenu}
27832779
onCellMouseDown={handleCellMouseDown}
27842780
onCellMouseEnter={handleCellMouseEnter}
2785-
isRowChecked={allRowsSelected || checkedRows.has(row.id)}
2781+
isRowChecked={rowSelectionIncludes(rowSelection, row.id)}
27862782
onRowToggle={handleRowToggle}
27872783
runningCount={runningByRowId.get(row.id) ?? 0}
27882784
hasWorkflowColumns={hasWorkflowColumns}

0 commit comments

Comments
 (0)