mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
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>
387 lines
11 KiB
TypeScript
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())
|
|
})
|