Files
Saxon FletcherandClaude Opus 5 9b12165400 feat(studio): multi-select logs with click, shift, and keyboard ranges (#50689)
<img width="1454" height="786" alt="image"
src="https://github.com/user-attachments/assets/995790b1-a242-4a61-a0a0-349de7234923"
/>


Stack 4/4 · previous: #50688

## Problem

Selecting several logs to copy or send to the Assistant meant using a
separate checkbox column and a banner above the table. Clicking a row
and checking a row were two different selections.

## Solution

- Unified Logs uses `useTableRowSelection`, so a row click,
Cmd/Ctrl-click, Shift-click, and the checkbox all act on one selection.
The selection resets when the project or filters change, and a linked
`?id=` still restores its log.
- The level indicator and checkbox now share one column. The checkbox
shows on hover, focus, or when the row is selected.
- The detail panel shows every selected log: one log gets the tabs, and
two or more show their combined JSON. Copy-as-JSON and "Explain with AI"
move into the panel header (`LogSelectionActions`), replacing
`RowSelectionHeader`.
- New Shift+↑ / Shift+↓ shortcuts extend the selection, with a hint in
the panel footer. Prev/next navigation follows display order and keeps
the row in view.
- `LogTypeIcon` no longer puts a nested button inside clickable rows.

## Review instructions

1. Click a log, then Shift-click another. The range should be selected
and the panel should show the combined JSON.
2. Cmd-click to add or remove single logs. Use the copy button (or its
shortcut) and "Explain with AI".
3. With the panel open, press Shift+↑ / Shift+↓ to grow and shrink the
selection.
4. Change a filter. The selection should clear.

## Checklist

- [ ] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)


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

## Summary

* **New Features**
* Added multi-row selection in Unified Logs, including additive, range,
checkbox, and keyboard selection.
* Added bulk JSON viewing and copying, plus AI-assisted actions for
selected logs.
* Added Shift+Arrow shortcuts to extend selections and improved row
navigation.
* **Accessibility**
* Improved labels and focus behavior for log controls and log type
indicators.
* **Bug Fixes**
* Kept single-log Overview and Raw JSON views available alongside
multi-log selection.
* Applied metadata visibility settings to selected-log actions and
disabled actions for invalid log data.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:03:01 +10:00

316 lines
12 KiB
TypeScript

import { QueryClient } from '@tanstack/react-query'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useQueryStates } from 'nuqs'
import { ResizablePanelGroup } from 'ui'
import { describe, expect, it, vi } from 'vitest'
import { generateDynamicColumns } from './components/Columns'
import { LogSelectionActions } from './LogSelectionActions'
import { ServiceFlowPanelControls } from './ServiceFlow/components/ServiceFlowPanelControls'
import { ServiceFlowPanel } from './ServiceFlowPanel'
import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
import { ColumnSchema } from './UnifiedLogs.schema'
import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite'
import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider'
import { RowSelectionModifiers } from '@/components/ui/DataTable/rowSelection.utils'
import { useTableRowSelection } from '@/components/ui/DataTable/useTableRowSelection'
import { mapUnifiedLogRow } from '@/data/logs/unified-logs.utils'
import { miscKeys } from '@/data/misc/keys'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
const logs: ColumnSchema[] = ['first', 'second', 'third', 'fourth'].map((id) => ({
id,
log_type: 'realtime',
event_message: id,
level: 'success',
timestamp: 1000000,
date: new Date(1000),
method: null,
pathname: null,
status: null,
}))
const { columns } = generateDynamicColumns({ data: logs })
function SelectionHarness({
scope = 'logs',
showPanel = false,
}: {
scope?: string
showPanel?: boolean
}) {
const [searchParameters] = useQueryStates(SEARCH_PARAMS_PARSER)
const { selection, selectRow, clearSelection } = useTableRowSelection({ scope })
const table = useReactTable({
data: logs,
columns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
state: { rowSelection: selection.selected },
})
const selectedRows = table.getSelectedRowModel().rows
const activeId =
selectedRows.find((row) => row.id === selection.activeId)?.id ?? selectedRows.at(-1)?.id
const onSelectRow = (id: string, modifiers?: RowSelectionModifiers) =>
selectRow(
logs.map((row) => row.id),
id,
modifiers
)
return (
<DataTableProvider
table={table}
columns={columns}
filterFields={[]}
error={null}
isError={false}
isLoading={false}
isFetching={false}
isLoadingCounts={false}
openRowId={activeId}
onSelectRow={onSelectRow}
setOpenRowId={(id) => (id ? onSelectRow(id) : clearSelection())}
>
<DataTableInfinite
columns={columns}
fetchNextPage={vi.fn()}
setColumnOrder={vi.fn()}
setColumnVisibility={vi.fn()}
/>
{activeId && !showPanel && <ServiceFlowPanelControls dock="right" setDock={vi.fn()} />}
{activeId && showPanel && (
<ResizablePanelGroup orientation="horizontal">
<ServiceFlowPanel
dock="right"
setDock={vi.fn()}
selectedRows={selectedRows.map((row) => row.original)}
searchParameters={searchParameters}
/>
</ResizablePanelGroup>
)}
</DataTableProvider>
)
}
const row = (message: string) => within(screen.getByRole('table')).getByText(message).closest('tr')!
const selected = () => screen.getAllByRole('row', { selected: true }).map((element) => element.id)
describe('log row selection', () => {
it('unifies row clicks, additive clicks, and checkbox toggles', () => {
customRender(<SelectionHarness />)
fireEvent.click(row('first'))
expect(selected()).toEqual(['first'])
expect(within(row('first')).getByRole('checkbox')).toBeChecked()
fireEvent.click(row('third'), { metaKey: true })
expect(selected()).toEqual(['first', 'third'])
fireEvent.click(within(row('second')).getByRole('checkbox'))
expect(selected()).toEqual(['first', 'second', 'third'])
fireEvent.click(within(row('first')).getByRole('checkbox'))
expect(selected()).toEqual(['second', 'third'])
fireEvent.click(row('fourth'))
expect(selected()).toEqual(['fourth'])
})
it('supports shift-click ranges and extends and shrinks with the keyboard', () => {
customRender(<SelectionHarness />)
fireEvent.click(row('second'))
fireEvent.click(row('fourth'), { shiftKey: true })
expect(selected()).toEqual(['second', 'third', 'fourth'])
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp', shiftKey: true })
fireEvent.keyUp(document, { key: 'ArrowUp', code: 'ArrowUp', shiftKey: true })
expect(selected()).toEqual(['second', 'third'])
fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown', shiftKey: true })
fireEvent.keyUp(document, { key: 'ArrowDown', code: 'ArrowDown', shiftKey: true })
expect(selected()).toEqual(['second', 'third', 'fourth'])
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
fireEvent.keyUp(document, { key: 'ArrowUp', code: 'ArrowUp' })
expect(selected()).toEqual(['third'])
})
it('supports keyboard activation and clears on closing or changing filters', () => {
const { rerender } = customRender(<SelectionHarness />)
fireEvent.keyDown(row('first'), { key: 'Enter' })
expect(selected()).toEqual(['first'])
fireEvent.keyDown(row('third'), { key: ' ', ctrlKey: true })
expect(selected()).toEqual(['first', 'third'])
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
expect(screen.queryAllByRole('row', { selected: true })).toHaveLength(0)
fireEvent.click(row('second'))
rerender(<SelectionHarness scope="new-filters" />)
expect(screen.queryAllByRole('row', { selected: true })).toHaveLength(0)
})
})
describe('selected log details', () => {
it.each([1788424716876000, '2026-09-03T09:58:36.876000'])(
'enables actions and copies mapped logs with timestamp %s',
async (timestamp) => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
const row = mapUnifiedLogRow({
id: 'mapped-log',
log_type: 'realtime',
timestamp,
event_message: 'Connection opened',
method: null,
pathname: null,
status: '200',
level: null,
log_count: null,
logs: null,
})
renderPanel(<LogSelectionActions rows={[row]} />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Explain with AI' })).not.toHaveAttribute(
'aria-disabled',
'true'
)
await user.click(screen.getByRole('button', { name: 'Copy selected logs' }))
expect(JSON.parse(copy.mock.calls[0][0])).toEqual([
expect.objectContaining({ id: 'mapped-log', timestamp, log_count: null, status: '200' }),
])
}
)
it('disables copy and AI actions when a selected log is invalid', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
renderPanel(<LogSelectionActions rows={[{ ...logs[0], timestamp: Infinity }]} />)
expect(screen.getByRole('alert')).toHaveTextContent('Selected logs contain invalid data')
expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Explain with AI' })).toHaveAttribute(
'aria-disabled',
'true'
)
await user.click(screen.getByRole('button', { name: 'Copy selected logs' }))
expect(copy).not.toHaveBeenCalled()
})
it.each([false, true])(
'copies selected logs with metadata visibility %s',
async (metadataVisible) => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
const rows = [
{
...logs[0],
event_message: undefined,
metadata: { source: 'worker_guest_logs' },
raw_log_data: {
event_message: 'Worker failed',
metadata: { request_id: 'request-id' },
},
},
{ ...logs[1], log_type: 'compute' as const, metadata: { host: 'compute-host' } },
]
renderPanel(<LogSelectionActions rows={rows} />, metadataVisible)
await user.click(screen.getByRole('button', { name: 'Copy selected logs' }))
expect(copy).toHaveBeenCalledOnce()
expect(JSON.parse(copy.mock.calls[0][0])).toEqual(
JSON.parse(
JSON.stringify([
{
...rows[0],
event_message: '',
metadata: metadataVisible ? rows[0].metadata : undefined,
raw_log_data: {
event_message: 'Worker failed',
metadata: metadataVisible ? { request_id: 'request-id' } : undefined,
},
},
{
id: rows[1].id,
timestamp: rows[1].timestamp,
event_message: rows[1].event_message,
metadata: metadataVisible ? rows[1].metadata : undefined,
},
])
)
)
expect(rows[0]).toMatchObject({
raw_log_data: { metadata: { request_id: 'request-id' } },
})
}
)
function renderPanel(children = <SelectionHarness showPanel />, metadataVisible = true) {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
response: {
cloud_provider: 'AWS',
db_host: 'db.default.supabase.co',
high_availability: false,
id: 1,
inserted_at: '2026-01-01T00:00:00Z',
integration_source: null,
is_branch_enabled: false,
is_physical_backups_enabled: false,
name: 'Test project',
organization_id: 1,
ref: 'default',
region: 'us-east-1',
restUrl: 'https://default.supabase.co',
status: 'ACTIVE_HEALTHY',
subscription_id: 'subscription-1',
updated_at: '2026-01-01T00:00:00Z',
connectionString: 'postgresql://postgres:password@localhost:5432/postgres',
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
queryClient.setQueryData(miscKeys.enabledFeaturesOverride(), {
disabled_features: metadataVisible ? [] : ['logs:metadata'],
})
return customRender(children, { queryClient })
}
it('offers both tabs for a log without a specialized overview', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(row('first'))
expect(screen.getByRole('tab', { name: 'Overview' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('event_message')).toBeVisible()
expect(screen.getByRole('button', { name: 'Copy selected logs' })).toBeVisible()
await user.click(screen.getByRole('tab', { name: 'Raw JSON' }))
expect(screen.getByRole('button', { name: 'Copy log as JSON' })).toBeVisible()
await user.click(screen.getByRole('tab', { name: 'Overview' }))
expect(screen.getByText('event_message')).toBeVisible()
})
it('shows and copies JSON directly for multiple logs and restores single-log tabs', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
renderPanel()
fireEvent.click(row('first'))
expect(screen.getByRole('status')).toHaveTextContent('first')
fireEvent.click(row('third'), { metaKey: true })
expect(screen.getByRole('status')).toHaveTextContent('2 logs selected')
expect(screen.queryByRole('tab')).not.toBeInTheDocument()
const json = screen.getByRole('region', { name: 'Selected logs JSON' })
expect(json).toHaveTextContent('first')
expect(json).toHaveTextContent('third')
expect(json).not.toHaveTextContent('second')
expect(screen.getByRole('button', { name: 'Explain with AI' })).not.toHaveAttribute(
'aria-haspopup'
)
expect(screen.getByRole('button', { name: 'Copy selected logs' }).textContent?.trim()).toBe('')
const copyButton = screen.getByRole('button', { name: 'Copy selected logs' })
expect(copyButton).not.toHaveAttribute('aria-haspopup')
await user.click(copyButton)
expect(copy).toHaveBeenCalledOnce()
expect(JSON.parse(copy.mock.calls[0][0])).toEqual(
JSON.parse(JSON.stringify([logs[0], logs[2]]))
)
await user.click(row('second'))
expect(screen.getByRole('status')).toHaveTextContent('second')
expect(screen.getByRole('tab', { name: 'Overview' })).toBeVisible()
expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeVisible()
})
})