Files
supabase/apps/studio/lib/shift-click-selection.ts
T
Alaister YoungandAlaister Young 89ae80073d [FE-4400] feat(studio): shift-click range selection in Unified Logs (#50462)
Shift-clicking a row checkbox in Unified Logs now selects every row
between the last clicked row and the clicked one, following up on
#50381. Per review, the legacy logs table now uses react-data-grid's
native shift-click selection (the same mechanism as the table editor)
instead of the custom anchor logic from #50381, and Unified Logs matches
the grid's semantics.

**Semantics (all three tables):** a shift-click applies the clicked
checkbox's new state to every row between the last clicked row and the
clicked one. The last clicked row itself is untouched. In Unified Logs a
shift-click after the selection has been cleared is a plain toggle.

**Changed:**
- `LogTable` passes `selectedRows`, `onSelectedRowsChange`, and
`rowKeyGetter` to the grid and renders the checkbox through a small
`LogSelectCell` component using `useRowSelection`. The custom anchor
ref, its resets, and the inline toggle are gone. Checking a row still
closes the single-row side panel.
- `getShiftClickSelection` moved from the Logs utils to
`apps/studio/lib/shift-click-selection.ts` and rewritten to the grid's
rule. Only Unified Logs uses it now, via a `getShiftClickRowSelection`
adapter for TanStack Table's `RowSelectionState`. Tests cover both.
- Unified Logs owns a selection anchor ref and passes it into the column
generator. The checkbox cell handles `onClick` with the shift key,
computes the range over the table's displayed row model (so it spans
sort order and infinite-scrolled pages), and writes back through the
table's own selection setter. Shift mousedown is prevented so no text
selection spans rows.
- The `LogTable` test mock of react-data-grid now implements the grid's
row selection so the component tests exercise the native path.

## To test

- Postgres logs: click one checkbox, then shift-click a checkbox further
down. Every row in between should be checked and the action bar shows
the count. Repeat upward.
- Shift-click an already-checked row: it and the rows back to the last
clicked row uncheck, the last clicked row stays as it was.
- Checking a box closes the single-row side panel. Clicking a row body
clears the selection and opens the panel.
- Tab to a checkbox and press Space: it still toggles. Arrow keys plus
Shift+Space still toggle the focused row.
- Unified Logs: same shift-click behavior. Clear the selection or change
a filter, then shift-click: only that one row toggles. Scroll to load
more rows and shift-click across the boundary.
- Copy as JSON/Markdown and Explain with AI still use the selected rows
in both tables.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Improved row selection in Settings Logs and Unified Logs.
- Shift-click now selects or deselects the range between the anchor row
and clicked row.
- Clicking an already selected row clears the relevant selection while
preserving the anchor row.
- Added more consistent checkbox, keyboard, and range-selection behavior
across log tables.
- Selecting a checkbox no longer opens the corresponding log, while
clicking the row continues to open it.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-25 15:38:23 +08:00

74 lines
2.1 KiB
TypeScript

import type { RowSelectionState } from '@tanstack/react-table'
/**
* Computes the next multi-select set after a shift-click on `targetKey`, matching
* react-data-grid's native behavior: the target's new checked state (the opposite of
* its current one) is applied to every key strictly between `anchorKey` (the last row
* the user clicked) and the target, plus the target itself. The anchor row is left
* untouched. Falls back to a plain toggle of `targetKey` when there is no usable anchor
* (null, no longer in `orderedKeys`, or the target itself).
*/
export function getShiftClickSelection({
orderedKeys,
selectedKeys,
anchorKey,
targetKey,
}: {
orderedKeys: string[]
selectedKeys: Set<string>
anchorKey: string | null
targetKey: string
}): Set<string> {
const next = new Set(selectedKeys)
const isChecked = !selectedKeys.has(targetKey)
const applyCheckedState = (key: string) => {
if (isChecked) {
next.add(key)
} else {
next.delete(key)
}
}
applyCheckedState(targetKey)
const anchorIndex = anchorKey === null ? -1 : orderedKeys.indexOf(anchorKey)
const targetIndex = orderedKeys.indexOf(targetKey)
const hasUsableAnchor = anchorIndex !== -1 && targetIndex !== -1 && anchorIndex !== targetIndex
if (!hasUsableAnchor) return next
const step = anchorIndex < targetIndex ? 1 : -1
for (let index = anchorIndex + step; index !== targetIndex; index += step) {
applyCheckedState(orderedKeys[index])
}
return next
}
/**
* `getShiftClickSelection` for TanStack Table's `RowSelectionState` (a map of row id → true).
*/
export function getShiftClickRowSelection({
orderedRowIds,
rowSelection,
anchorRowId,
targetRowId,
}: {
orderedRowIds: string[]
rowSelection: RowSelectionState
anchorRowId: string | null
targetRowId: string
}): RowSelectionState {
const selectedRowIds = new Set(Object.keys(rowSelection).filter((id) => rowSelection[id]))
const next = getShiftClickSelection({
orderedKeys: orderedRowIds,
selectedKeys: selectedRowIds,
anchorKey: anchorRowId,
targetKey: targetRowId,
})
return Object.fromEntries([...next].map((id) => [id, true]))
}