Files
supabase/apps/studio/lib/shift-click-selection.test.ts
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

196 lines
5.5 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import { getShiftClickRowSelection, getShiftClickSelection } from './shift-click-selection'
describe('getShiftClickSelection', () => {
const orderedKeys = ['a', 'b', 'c', 'd', 'e']
test('selects the range downward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['b']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('selects the range upward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['d']),
anchorKey: 'd',
targetKey: 'b',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('deselects the range when the target is already selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'b', 'c', 'd']),
anchorKey: 'a',
targetKey: 'd',
})
// The anchor row itself is never modified, so 'a' stays selected
expect([...result].sort()).toEqual(['a'])
})
test('leaves the anchor row untouched when it is not selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['c', 'd'])
})
test('applies the target state to the whole range when it is partially selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'a',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('toggles just the target when it is also the anchor', () => {
const added = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'c',
targetKey: 'c',
})
expect([...added]).toEqual(['c'])
const removed = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'c',
targetKey: 'c',
})
expect([...removed]).toEqual([])
})
test('falls back to adding the target when there is no anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a', 'c'])
})
test('falls back to removing the target when there is no anchor and it is selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'c']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a'])
})
test('falls back to a plain toggle when the anchor is no longer in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'gone',
targetKey: 'd',
})
expect([...result]).toEqual(['d'])
})
test('falls back to a plain toggle when the target is not in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: 'a',
targetKey: 'gone',
})
expect([...result].sort()).toEqual(['a', 'gone'])
})
test('does not mutate its inputs', () => {
const selectedKeys = new Set(['b'])
const keys = [...orderedKeys]
const result = getShiftClickSelection({
orderedKeys: keys,
selectedKeys,
anchorKey: 'b',
targetKey: 'd',
})
expect(result).not.toBe(selectedKeys)
expect([...selectedKeys]).toEqual(['b'])
expect(keys).toEqual(orderedKeys)
})
})
describe('getShiftClickRowSelection', () => {
const orderedRowIds = ['a', 'b', 'c', 'd', 'e']
test('selects the range between the anchor and the target', () => {
const result = getShiftClickRowSelection({
orderedRowIds,
rowSelection: { b: true },
anchorRowId: 'b',
targetRowId: 'd',
})
expect(result).toEqual({ b: true, c: true, d: true })
})
test('deselects the range when the target is already selected', () => {
const result = getShiftClickRowSelection({
orderedRowIds,
rowSelection: { a: true, b: true, c: true, d: true },
anchorRowId: 'a',
targetRowId: 'd',
})
expect(result).toEqual({ a: true })
})
test('falls back to a plain toggle when there is no anchor', () => {
const added = getShiftClickRowSelection({
orderedRowIds,
rowSelection: { a: true },
anchorRowId: null,
targetRowId: 'c',
})
expect(added).toEqual({ a: true, c: true })
const removed = getShiftClickRowSelection({
orderedRowIds,
rowSelection: { a: true, c: true },
anchorRowId: null,
targetRowId: 'c',
})
expect(removed).toEqual({ a: true })
})
test('ignores ids explicitly set to false and never returns false entries', () => {
const result = getShiftClickRowSelection({
orderedRowIds,
rowSelection: { a: false, b: true, c: false },
anchorRowId: 'b',
targetRowId: 'd',
})
expect(result).toEqual({ b: true, c: true, d: true })
expect(Object.values(result).every((isSelected) => isSelected === true)).toBe(true)
})
test('does not mutate the row selection it is given', () => {
const rowSelection = { b: true }
const result = getShiftClickRowSelection({
orderedRowIds,
rowSelection,
anchorRowId: 'b',
targetRowId: 'd',
})
expect(result).not.toBe(rowSelection)
expect(rowSelection).toEqual({ b: true })
})
})