Files
supabase/apps/studio/tests/components/UnifiedLogs/LogSelectionActions.utils.test.ts
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

142 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { buildLogsPrompt, formatLogsAsJson } from '@/components/interfaces/Settings/Logs/Logs.utils'
import { parseSelectedLogs } from '@/components/interfaces/UnifiedLogs/LogSelectionActions.utils'
import type { ColumnSchema } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.schema'
import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from '@/data/logs/unified-logs.utils'
const row: ColumnSchema = {
id: 'log-id',
log_type: 'realtime',
timestamp: 1_000_000,
date: new Date(1000),
event_message: 'Connection opened',
method: null,
pathname: null,
level: null,
status: null,
}
describe('parseSelectedLogs', () => {
it.each(['edge', 'auth', 'compute', 'realtime'])(
'accepts mapped %s logs with numeric and OTEL timestamps',
(logType) => {
const timestamps = [
1788424716876000,
'1788424716876000',
'2026-09-03T09:58:36.876000',
'2026-09-03T09:58:36.876Z',
]
const rows = parseUnifiedLogsQueryRows(
timestamps.map((timestamp) => ({
id: `${logType}-${timestamp}`,
log_type: logType,
timestamp,
event_message: 'Connection opened',
method: null,
pathname: null,
status: '200',
level: null,
log_count: null,
logs: null,
}))
).map(mapUnifiedLogRow)
const result = parseSelectedLogs(rows, true)
expect(result.success).toBe(true)
if (!result.success) throw result.error
expect(result.data.map((log) => log.timestamp)).toEqual(timestamps)
expect(JSON.parse(formatLogsAsJson(result.data))).toHaveLength(timestamps.length)
expect(buildLogsPrompt(result.data)).toContain('Connection opened')
if (logType !== 'compute') expect(result.data[0].log_count).toBeNull()
}
)
it.each([false, true])('preserves log fields with metadata visibility %s', (metadataVisible) => {
const input = {
...row,
metadata: { host: 'host' },
raw_log_data: { message: 'original', metadata: { request_id: 'request' } },
custom_field: 'custom',
}
const result = parseSelectedLogs([input], metadataVisible)
expect(result).toMatchObject({
success: true,
data: [
{
...input,
metadata: metadataVisible ? input.metadata : undefined,
raw_log_data: {
message: 'original',
metadata: metadataVisible ? input.raw_log_data.metadata : undefined,
},
},
],
})
expect(input.raw_log_data.metadata).toEqual({ request_id: 'request' })
expect(input.metadata).toEqual({ host: 'host' })
})
it.each([false, true])(
'normalizes compute logs with metadata visibility %s',
(metadataVisible) => {
const result = parseSelectedLogs(
[{ ...row, log_type: 'compute', event_message: undefined, metadata: { host: 'host' } }],
metadataVisible
)
expect(result).toEqual({
success: true,
data: [
{
id: row.id,
timestamp: row.timestamp,
event_message: '',
metadata: metadataVisible ? { host: 'host' } : undefined,
},
],
})
}
)
it('accepts an empty selection', () => {
expect(parseSelectedLogs([], true)).toEqual({ success: true, data: [] })
})
it('normalizes nullable metadata', () => {
expect(parseSelectedLogs([{ ...row, metadata: null }], true)).toMatchObject({
success: true,
data: [{ metadata: undefined }],
})
})
it.each([
null,
{},
[null],
[{ ...row, id: undefined }],
[{ ...row, event_message: 123 }],
[{ ...row, log_type: 'unknown' }],
[{ ...row, metadata: 'invalid' }],
[{ ...row, timestamp: true }],
[{ ...row, timestamp: 'invalid timestamp' }],
[{ ...row, timestamp: '' }],
])('returns a validation error for malformed input: %j', (input) => {
expect(parseSelectedLogs(input, false).success).toBe(false)
})
it('validates the transformed output and rejects the whole selection', () => {
const result = parseSelectedLogs([row, { ...row, timestamp: Infinity }], true)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.issues).toEqual([
expect.objectContaining({ code: 'not_finite', path: [1, 'timestamp'] }),
])
}
})
})