Skip to content

Commit 3913b49

Browse files
fix(tables): keyboard scroll past sticky header, copy toast row count, clipboard error handling
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8956ef5 commit 3913b49

2 files changed

Lines changed: 96 additions & 45 deletions

File tree

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

Lines changed: 88 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
66
import { useVirtualizer } from '@tanstack/react-virtual'
77
import { useParams } from 'next/navigation'
88
import { usePostHog } from 'posthog-js/react'
9-
import { Skeleton, toast } from '@/components/emcn'
9+
import { Skeleton, toast, useToast } from '@/components/emcn'
1010
import { TableX } from '@/components/emcn/icons'
1111
import type { RunMode } from '@/lib/api/contracts/tables'
1212
import { cn } from '@/lib/core/utils/cn'
@@ -317,6 +317,9 @@ export function TableGrid({
317317
const totalRunning = tableRunState?.runningCellCount ?? 0
318318
const runningByRowId = tableRunState?.runningByRowId ?? EMPTY_RUNNING_BY_ROW
319319

320+
const tableRowCountRef = useRef(tableData?.rowCount ?? 0)
321+
tableRowCountRef.current = tableData?.rowCount ?? 0
322+
320323
const fetchNextPageRef = useRef(fetchNextPage)
321324
fetchNextPageRef.current = fetchNextPage
322325
const hasNextPageRef = useRef(hasNextPage)
@@ -356,7 +359,8 @@ export function TableGrid({
356359
useLayoutEffect(() => {
357360
const el = theadRef.current
358361
if (!el) return
359-
const measure = () => setHeaderHeight(el.offsetHeight)
362+
const measure = () =>
363+
setHeaderHeight((prev) => (prev === el.offsetHeight ? prev : el.offsetHeight))
360364
measure()
361365
const observer = new ResizeObserver(measure)
362366
observer.observe(el)
@@ -374,6 +378,9 @@ export function TableGrid({
374378
const userPermissions = useUserPermissionsContext()
375379
const canEditRef = useRef(userPermissions.canEdit)
376380
canEditRef.current = userPermissions.canEdit
381+
const { dismiss: dismissToast } = useToast()
382+
const dismissToastRef = useRef(dismissToast)
383+
dismissToastRef.current = dismissToast
377384
// Refs for callback props read inside effects with stable empty deps.
378385
const onOpenRowModalRef = useRef(onOpenRowModal)
379386
onOpenRowModalRef.current = onOpenRowModal
@@ -1483,26 +1490,46 @@ export function TableGrid({
14831490
if (!target) return
14841491
const { rowIndex, colIndex } = target
14851492
const selector = `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]`
1493+
// `scrollIntoView` ignores the sticky `<thead>` and sticky gutter, so a cell
1494+
// scrolled to the edge lands behind them. Scroll manually with insets equal
1495+
// to the sticky header height (top) and the row-number column width (left).
1496+
const revealCell = (cell: HTMLElement) => {
1497+
const scrollEl = scrollRef.current
1498+
if (!scrollEl) return
1499+
const view = scrollEl.getBoundingClientRect()
1500+
const rect = cell.getBoundingClientRect()
1501+
const topInset = theadRef.current?.offsetHeight ?? 0
1502+
if (rect.top < view.top + topInset) {
1503+
scrollEl.scrollTop -= view.top + topInset - rect.top
1504+
} else if (rect.bottom > view.bottom) {
1505+
scrollEl.scrollTop += rect.bottom - view.bottom
1506+
}
1507+
if (rect.left < view.left + checkboxColWidth) {
1508+
scrollEl.scrollLeft -= view.left + checkboxColWidth - rect.left
1509+
} else if (rect.right > view.right) {
1510+
scrollEl.scrollLeft += rect.right - view.right
1511+
}
1512+
}
14861513
let secondRaf = 0
14871514
const rafId = requestAnimationFrame(() => {
14881515
const cell = document.querySelector(selector) as HTMLElement | null
14891516
if (cell) {
1490-
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' })
1517+
revealCell(cell)
14911518
return
14921519
}
14931520
// Target row is windowed out (large jump / PageUp-Down). Bring it into the
1494-
// virtualized range first, then align horizontally once it has rendered.
1521+
// virtualized range first, then align once it has rendered.
14951522
rowVirtualizer.scrollToIndex(rowIndex, { align: 'auto' })
14961523
secondRaf = requestAnimationFrame(() => {
14971524
const rendered = document.querySelector(selector) as HTMLElement | null
1498-
rendered?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
1525+
if (rendered) revealCell(rendered)
14991526
})
15001527
})
15011528
return () => {
15021529
cancelAnimationFrame(rafId)
15031530
if (secondRaf) cancelAnimationFrame(secondRaf)
15041531
}
1505-
}, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer])
1532+
}, [selectionAnchor, selectionFocus, isColumnSelection, rowVirtualizer, checkboxColWidth])
15061533

15071534
const handleCellClick = useCallback(
15081535
(rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => {
@@ -1973,13 +2000,20 @@ export function TableGrid({
19732000
selectRow: (row: TableRowType) => boolean
19742001
buildCells: (row: TableRowType) => string[]
19752002
verb: 'Copied' | 'Cut'
2003+
/** Best-known row count for the in-progress toast (exact count is shown on completion). */
2004+
estimatedCount: number
19762005
afterCopy?: (copiedRows: TableRowType[]) => Promise<void> | void
19772006
}) => {
19782007
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard) {
19792008
toast.error('Clipboard access is unavailable in this context')
19802009
return
19812010
}
1982-
toast({ message: `${opts.verb === 'Copied' ? 'Copying' : 'Cutting'}… loading rows` })
2011+
const isCopy = opts.verb === 'Copied'
2012+
const verbLower = isCopy ? 'copy' : 'cut'
2013+
const estimate = opts.estimatedCount
2014+
const loadingToastId = toast({
2015+
message: `${isCopy ? 'Copying' : 'Cutting'} ${estimate.toLocaleString()} ${estimate === 1 ? 'row' : 'rows'}…`,
2016+
})
19832017
let rowCount = 0
19842018
let truncated = false
19852019
const copiedRows: TableRowType[] = []
@@ -1999,27 +2033,45 @@ export function TableGrid({
19992033
rowCount = lines.length
20002034
return new Blob([lines.join('\n')], { type: 'text/plain' })
20012035
})()
2002-
void navigator.clipboard
2003-
.write([new ClipboardItem({ 'text/plain': blob })])
2004-
.then(async () => {
2036+
// `.write()` is invoked synchronously so the copy/cut gesture's transient
2037+
// activation survives the async row load inside the blob promise.
2038+
const writePromise = navigator.clipboard.write([new ClipboardItem({ 'text/plain': blob })])
2039+
void (async () => {
2040+
try {
2041+
await writePromise
2042+
} catch (error) {
2043+
// Rejects if the row load failed or the payload is too large for the
2044+
// clipboard — either way nothing landed, so report a plain failure
2045+
// rather than implying a size cap was hit.
2046+
logger.error(`Failed to ${verbLower} rows`, { error })
2047+
dismissToastRef.current(loadingToastId)
2048+
toast.error(`Failed to ${verbLower} — please try again`)
2049+
return
2050+
}
2051+
// The clipboard now holds the data; a clear failure must not be reported
2052+
// as a copy/cut failure.
2053+
try {
20052054
await opts.afterCopy?.(copiedRows)
2006-
if (truncated) {
2007-
toast({
2008-
message: `${opts.verb} first ${TABLE_LIMITS.MAX_COPY_ROWS.toLocaleString()} rows — export to CSV for the rest`,
2009-
})
2010-
} else {
2011-
toast.success(
2012-
`${opts.verb} ${rowCount.toLocaleString()} ${rowCount === 1 ? 'row' : 'rows'}`
2013-
)
2014-
}
2015-
})
2016-
.catch((error) => {
2017-
logger.error(`Failed to ${opts.verb === 'Copied' ? 'copy' : 'cut'} rows`, { error })
2018-
toast.error('Selection too large to copy — use Export CSV')
2019-
})
2055+
} catch (error) {
2056+
logger.error('Failed to clear cut cells', { error })
2057+
dismissToastRef.current(loadingToastId)
2058+
toast.error('Copied to clipboard, but clearing the cells failed — please try again')
2059+
return
2060+
}
2061+
dismissToastRef.current(loadingToastId)
2062+
if (truncated) {
2063+
toast({
2064+
message: `${opts.verb} first ${TABLE_LIMITS.MAX_COPY_ROWS.toLocaleString()} rows — export to CSV for the rest`,
2065+
})
2066+
} else {
2067+
toast.success(
2068+
`${opts.verb} ${rowCount.toLocaleString()} ${rowCount === 1 ? 'row' : 'rows'}`
2069+
)
2070+
}
2071+
})()
20202072
}
20212073

2022-
/** Clears `colNames` on `rowsToClear` (the cut tail) and records an undo entry. */
2074+
/** Clears `colNames` on `rowsToClear` (the cut tail), recording undo only after the update lands. */
20232075
const clearCutRows = async (rowsToClear: TableRowType[], colNames: string[]) => {
20242076
const undo: Array<{ rowId: string; data: Record<string, unknown> }> = []
20252077
const updates: Array<{ rowId: string; data: Record<string, unknown> }> = []
@@ -2033,8 +2085,8 @@ export function TableGrid({
20332085
undo.push({ rowId: row.id, data: previousData })
20342086
updates.push({ rowId: row.id, data: nextData })
20352087
}
2036-
if (undo.length > 0) pushUndoRef.current({ type: 'clear-cells', cells: undo })
20372088
if (updates.length > 0) await chunkBatchUpdates(updates, batchUpdateAsyncRef.current)
2089+
if (undo.length > 0) pushUndoRef.current({ type: 'clear-cells', cells: undo })
20382090
}
20392091

20402092
const handleCopy = (e: ClipboardEvent) => {
@@ -2056,6 +2108,7 @@ export function TableGrid({
20562108
selectRow: (row) => rowSelectionIncludes(rowSel, row.id),
20572109
buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])),
20582110
verb: 'Copied',
2111+
estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current,
20592112
})
20602113
return
20612114
}
@@ -2069,19 +2122,17 @@ export function TableGrid({
20692122
e.preventDefault()
20702123

20712124
if (isColumnSelectionRef.current) {
2125+
const colNames: string[] = []
2126+
for (let c = sel.startCol; c <= sel.endCol; c++) {
2127+
const name = cols[c]?.name
2128+
if (name) colNames.push(name)
2129+
}
20722130
writeSelectionToClipboard({
20732131
loadRows: () => ensureRowsLoadedUpToRef.current(TABLE_LIMITS.MAX_COPY_ROWS),
20742132
selectRow: () => true,
2075-
buildCells: (row) => {
2076-
const cells: string[] = []
2077-
for (let c = sel.startCol; c <= sel.endCol; c++) {
2078-
const colName = cols[c]?.name
2079-
if (!colName) continue
2080-
cells.push(cellToText(row.data[colName]))
2081-
}
2082-
return cells
2083-
},
2133+
buildCells: (row) => colNames.map((name) => cellToText(row.data[name])),
20842134
verb: 'Copied',
2135+
estimatedCount: tableRowCountRef.current,
20852136
})
20862137
return
20872138
}
@@ -2119,6 +2170,7 @@ export function TableGrid({
21192170
selectRow: (row) => rowSelectionIncludes(rowSel, row.id),
21202171
buildCells: (row) => cols.map((col) => cellToText(row.data[col.name])),
21212172
verb: 'Cut',
2173+
estimatedCount: rowSel.kind === 'some' ? rowSel.ids.size : tableRowCountRef.current,
21222174
afterCopy: (copied) =>
21232175
clearCutRows(
21242176
copied,
@@ -2147,6 +2199,7 @@ export function TableGrid({
21472199
selectRow: () => true,
21482200
buildCells: (row) => colNames.map((name) => cellToText(row.data[name])),
21492201
verb: 'Cut',
2202+
estimatedCount: tableRowCountRef.current,
21502203
afterCopy: (copied) => clearCutRows(copied, colNames),
21512204
})
21522205
return

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,13 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
138138
sort: queryOptions.sort,
139139
})
140140

141-
const loadedCount = (): number =>
142-
queryClient.getQueryData(opts.queryKey)?.pages.reduce((sum, p) => sum + p.rows.length, 0) ??
143-
0
144-
145-
while (loadedCount() < maxRows) {
141+
// Load one past the cap so `hasMore` is exact: a full final page only
142+
// *might* have a successor, so we confirm by loading row `maxRows + 1`
143+
// rather than inferring truncation from page fullness.
144+
while (true) {
146145
const data = queryClient.getQueryData(opts.queryKey)
146+
const loaded = data?.pages.reduce((sum, p) => sum + p.rows.length, 0) ?? 0
147+
if (loaded > maxRows) break
147148
const lastPage = data?.pages[data.pages.length - 1]
148149
if (!lastPage || lastPage.rows.length < TABLE_LIMITS.MAX_QUERY_LIMIT) break
149150
const result = await fetchNextPage()
@@ -152,13 +153,10 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
152153
}
153154
}
154155

155-
const data = queryClient.getQueryData(opts.queryKey)
156-
const all = data?.pages.flatMap((p) => p.rows) ?? []
157-
const lastPage = data?.pages[data.pages.length - 1]
158-
const morePages = lastPage ? lastPage.rows.length === TABLE_LIMITS.MAX_QUERY_LIMIT : false
156+
const all = queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? []
159157
return {
160158
rows: all.length > maxRows ? all.slice(0, maxRows) : all,
161-
hasMore: morePages || all.length > maxRows,
159+
hasMore: all.length > maxRows,
162160
}
163161
},
164162
[workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage]

0 commit comments

Comments
 (0)