Files
supabase/apps/studio/tests/features/logs/LogTable.test.tsx
Alaister YoungandAlaister Young 240bfce7f6 [FE-4198] feat(studio): select a range of logs with shift-click (#50381)
Shift-clicking a log row checkbox now selects every row between the last
clicked row and the clicked one, so you can grab a consecutive block of
logs to copy without checking each one. Applies everywhere the shared
`LogTable` renders: Postgres/API/Auth/Edge Functions logs and the Logs
Explorer.

**Added:**
- `getShiftClickSelection` in `Logs.utils.ts`: pure helper that computes
the next selection from the ordered row keys, the current selection, the
anchor row, and the clicked row. Adds the inclusive range in either
direction. If the whole range is already selected it deselects the range
instead. Falls back to a plain toggle when there's no usable anchor.
Covered by unit tests, plus `LogTable` component tests for range select,
the no-anchor fallback, anchor clearing, and range deselect.

**Changed:**
- `LogTable` tracks the last toggled row as the range anchor (a ref,
since it's only read in handlers). The anchor is set by plain clicks,
shift-clicks, and the Shift+Space row toggle, and cleared whenever the
selection becomes empty (toggling off the last row, plain row click,
Escape, action bar clear, select-all then deselect-all, or a new query
loading).
- The checkbox cell handles `onClick` instead of `onCheckedChange` so
the shift key is available. Keyboard Space on a focused checkbox still
toggles it, since Radix dispatches a click for it.
- A shift mousedown on the checkbox cell is prevented so the browser
doesn't start a text selection across rows.

Unified Logs has its own row selection (TanStack Table) and is not
changed here.

## To test

- Open any log page with a decent number of rows, e.g. Postgres logs.
Click one checkbox, then shift-click a checkbox several rows below.
Every row in between should be checked and the action bar should show
the count. Repeat upward.
- Shift-click a range that's already fully selected: the range should
clear, and rows outside it stay as they were.
- Plain-click a row's message text (not the checkbox): side panel opens
and the selection clears. A following shift-click should just toggle
that one row.
- Press Escape or the action bar's clear button, then shift-click: also
just a single toggle.
- Focus a row with the arrow keys, press Shift+Space, then shift-click a
lower checkbox: the range should extend from the keyboard-toggled row.
- Tab to a checkbox and press Space: it should still toggle.
- After a shift-click, confirm no text is highlighted across the rows.
- Copy as JSON/Markdown/CSV still copies the selected rows in display
order.


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

- **New Features**
- Added shift-click range selection to the logs table for selecting or
deselecting consecutive rows.
  - Preserved single-row selection when range selection is unavailable.
- Improved selection behavior when clearing selections or changing log
queries, preventing stale range anchors.

- **Tests**
- Added coverage for forward and reverse range selection, deselection,
partial selections, fallback behavior, and input immutability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-09-16 18:18:28 +08:00

387 lines
11 KiB
TypeScript

import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { expect, test, vi } from 'vitest'
import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
import { customRender as render } from '@/tests/lib/custom-render'
dayjs.extend(customParseFormat)
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(relativeTime)
vi.mock('next/router', () => import('next-router-mock'))
vi.mock('react-data-grid', () => ({
default: ({ columns, rows, renderers, role, headerRowHeight }: any) => (
<div role={role ?? 'table'}>
{headerRowHeight !== 0 && (
<div role="row">
{columns.map((col: any, colIdx: number) => (
<div key={colIdx} role="columnheader">
{col.renderHeaderCell ? col.renderHeaderCell({}) : col.name}
</div>
))}
</div>
)}
{rows.map((row: any, rowIdx: number) => (
<div key={rowIdx} role="row">
{columns.map((col: any, colIdx: number) => (
<div key={colIdx} role="cell">
{col.renderCell?.({ row, rowIdx, isCellSelected: false })}
</div>
))}
</div>
))}
{rows.length === 0 && renderers?.noRowsFallback}
</div>
),
Row: ({ row, ...props }: any) => <div role="row" {...props} />,
}))
const fakeMicroTimestamp = dayjs().unix() * 1000
const LOG_DATA = {
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'event message',
metadata: {
my_key: 'something_value',
},
}
test('can display log data', async () => {
render(
<>
<LogTable projectRef="default" data={[LOG_DATA]} />
</>
)
await screen.findAllByText(LOG_DATA.timestamp)
})
test('Shows total results', async () => {
render(<LogTable projectRef="default" data={[LOG_DATA]} />)
await screen.getByText(/results \(1\)/i)
})
test('can run if no queryType provided', async () => {
const mockRun = vi.fn()
render(
<LogTable
projectRef="projectRef"
data={[
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {
my_key: 'something_value',
},
},
]}
onRun={mockRun}
/>
)
const run = await screen.findByText('Run')
await userEvent.click(run)
// expect(mockRun).toBeCalled()
})
test('can run if no queryType provided', async () => {
const mockRun = vi.fn()
render(
<LogTable
data={[
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {
my_key: 'something_value',
},
},
]}
projectRef="abcd"
onRun={mockRun}
/>
)
const run = await screen.findByText('Run')
await userEvent.click(run)
// expect(mockRun).toBeCalled()
})
test('dedupes log lines with exact id', async () => {
// chronological mode requires 4 columns
render(
<LogTable
projectRef="projectRef"
data={[
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {},
},
{
id: 'some-uuid',
timestamp: 1621323232312,
event_message: 'some event happened',
metadata: {},
},
]}
/>
)
// should only have one element, this line will fail if there are >1 element
await screen.findByText('timestamp')
})
test('can display standard preview table columns', async () => {
render(
<LogTable
projectRef="ref"
queryType="auth"
data={[{ id: '12345', event_message: 'some event message', timestamp: fakeMicroTimestamp }]}
/>
)
await waitFor(() => screen.getByText(/some event message/))
await expect(screen.findByText(/12345/)).rejects.toThrow()
await expect(screen.findByText(fakeMicroTimestamp)).rejects.toThrow()
})
test("closes the selection if the selected row's data changes", async () => {
const { rerender } = render(
<LogTable
projectRef="ref"
queryType="auth"
data={[{ id: '1', event_message: 'some event message', timestamp: fakeMicroTimestamp }]}
/>
)
const text = await screen.findByText(/some event message/)
await userEvent.click(text)
rerender(
<LogTable
projectRef="ref"
queryType="auth"
data={[{ id: '2', event_message: 'some other message', timestamp: fakeMicroTimestamp }]}
/>
)
await expect(screen.findByText(/some event message/)).rejects.toThrow()
await screen.findByText(/some other message/)
})
enum QueryType {
Functions = 'functions',
Api = 'api',
Auth = 'auth',
}
test.each([
{
queryType: QueryType.Functions,
data: [
{
event_message: 'This is a error log\n',
event_type: 'log',
function_id: '001b0b08-331c-403e-810c-a2004b03a019',
level: 'error',
timestamp: 1659545029083869,
id: '3475cf6f-2929-4296-ab44-ce2c17069937',
},
],
includes: [/ERROR/],
excludes: ['undefined', 'null'],
},
{
queryType: QueryType.Functions,
data: [
{
event_message: 'This is a uncaughtExceptop\n',
event_type: 'uncaughtException',
function_id: '001b0b08-331c-403e-810c-a2004b03a019',
timestamp: 1659545029083869,
id: '4475cf6f-2929-4296-ab44-ce2c17069937',
level: undefined,
},
],
includes: [/uncaughtException/],
excludes: [/ERROR/],
},
{
queryType: QueryType.Api,
data: [
{
event_message: 'This is a uncaughtException\n',
path: 'this-is-some-path',
method: 'POST',
status_code: 500,
timestamp: 1659545029083869,
id: '4475cf6f-2929-4296-ab44-ce2c17069937',
},
],
includes: [/POST/, 'this-is-some-path'],
excludes: [],
},
{
queryType: QueryType.Auth,
data: [
{
event_message: JSON.stringify({ msg: 'some message', path: '/auth-path', level: 'info' }),
msg: 'some message',
path: '/auth-path',
level: 'info',
timestamp: 1659545029083869,
id: '4475cf6f-2929-4296-ab44-ce2c17069937',
},
],
includes: [/auth\-path/, /some message/, /INFO/],
excludes: [/\{/, /\}/],
},
])('table col renderer for $queryType', async ({ queryType, data, includes, excludes }) => {
render(<LogTable projectRef="ref" queryType={queryType} data={data} />)
await Promise.all([
...includes.map((text) => screen.findByText(text)),
...excludes.map((text) => expect(screen.findByText(text)).rejects.toThrow()),
])
})
test('error message handling', async () => {
// Render LogTable with error as a string
render(<LogTable projectRef="ref" error={'some error message'} />)
expect(screen.getByText(`some error message`)).toBeTruthy()
// Rerender LogTable with error as null
render(<LogTable projectRef="ref" error={null} />)
// Add any additional assertions if LogTable behaves differently when error is null
})
test('no results message handling', async () => {
render(<LogTable projectRef="ref" data={[]} />)
await screen.findByText(/No results/)
await screen.findByText(/Try another search/)
})
test('custom error message: Resources exceeded during query execution', async () => {
const errorFromLogflare = {
error: {
code: 400,
errors: [
{
domain: 'global',
message:
'Resources exceeded during query execution: The query could not be executed in the allotted memory. Peak usage: 122% of limit.\nTop memory consumer(s):\n ORDER BY operations: 99%\n other/unattributed: 1%\n',
reason: 'resourcesExceeded',
},
],
message:
'Resources exceeded during query execution: The query could not be executed in the allotted memory. Peak usage: 122% of limit.\nTop memory consumer(s):\n ORDER BY operations: 99%\n other/unattributed: 1%\n',
status: 'INVALID_ARGUMENT',
},
}
// logs explorer, custom query
const { rerender } = render(<LogTable projectRef="ref" error={errorFromLogflare} />)
// prompt user to reduce selected tables
await screen.findByText(/This query requires too much memory to be executed/)
await screen.findByText(
/Avoid selecting entire objects and instead select specific keys using dot notation/
)
// previewer, prompt to reduce time range
rerender(<LogTable projectRef="ref" queryType="api" error={errorFromLogflare} />)
await screen.findByText(/This query requires too much memory to be executed/)
await screen.findByText(/Avoid querying across a large datetime range/)
await screen.findByText(/Please contact support if this error persists/)
})
const createLogRow = (index: number) => ({
id: `log-id-${index}`,
timestamp: fakeMicroTimestamp - index,
event_message: `event message ${index}`,
})
const MULTI_SELECT_LOGS = Array.from({ length: 6 }, (_, index) => createLogRow(index))
const getRowCheckboxes = () => screen.getAllByRole('checkbox')
const expectCheckedIndexes = (checkedIndexes: number[]) => {
const checkboxes = getRowCheckboxes()
checkboxes.forEach((checkbox, index) => {
expect(checkbox.getAttribute('aria-checked')).toBe(
checkedIndexes.includes(index) ? 'true' : 'false'
)
})
}
const shiftClick = async (user: ReturnType<typeof userEvent.setup>, element: Element) => {
await user.keyboard('{Shift>}')
await user.click(element)
await user.keyboard('{/Shift}')
}
test('shift-click selects the range between the anchor row and the clicked row', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
const checkboxes = getRowCheckboxes()
expect(checkboxes).toHaveLength(MULTI_SELECT_LOGS.length)
await user.click(checkboxes[1])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([1, 2, 3, 4])
await screen.findByText('4 rows selected')
})
test('shift-click without an anchor toggles only the clicked row', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await shiftClick(user, getRowCheckboxes()[3])
expectCheckedIndexes([3])
await screen.findByText('1 row selected')
})
test('clears the shift-click anchor once the selection becomes empty', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await user.click(getRowCheckboxes()[1])
await user.click(getRowCheckboxes()[1])
expectCheckedIndexes([])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([4])
await screen.findByText('1 row selected')
})
test('shift-click deselects a range that is already fully selected', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await user.click(getRowCheckboxes()[1])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([1, 2, 3, 4])
await shiftClick(user, getRowCheckboxes()[1])
expectCheckedIndexes([])
await waitFor(() => expect(screen.queryByText(/rows? selected/)).toBeNull())
})