mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
[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>
This commit is contained in:
1 parent
99be7f92ce
commit
240bfce7f6
4 files changed
+278
-8
No files matched your search
@@ -30,6 +30,7 @@ import {
|
||||
formatLogsAsCsv,
|
||||
formatLogsAsJson,
|
||||
formatLogsAsMarkdown,
|
||||
getShiftClickSelection,
|
||||
isDefaultLogPreviewFormat,
|
||||
} from './Logs.utils'
|
||||
import LogSelection from './LogSelection'
|
||||
@@ -106,6 +107,8 @@ export const LogTable = ({
|
||||
const [selectedLogId] = useSelectedLog()
|
||||
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
|
||||
// Last row key the user toggled, used as the start of a shift-click range
|
||||
const selectionAnchorRef = useRef<string | null>(null)
|
||||
const [copiedFormat, setCopiedFormat] = useState<LogCopyFormat | null>(null)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const [activeRow, setActiveRow] = useState<LogData | null>(null)
|
||||
@@ -196,13 +199,24 @@ export const LogTable = ({
|
||||
minWidth: 32,
|
||||
renderCell: ({ row }) => {
|
||||
const key = getRowKey(row)
|
||||
const toggle = () => {
|
||||
const next = new Set(selectedRows)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
const toggle = (isShiftClick: boolean) => {
|
||||
let next: Set<string>
|
||||
if (isShiftClick) {
|
||||
next = getShiftClickSelection({
|
||||
orderedKeys: logDataRows.map(getRowKey),
|
||||
selectedKeys: selectedRows,
|
||||
anchorKey: selectionAnchorRef.current,
|
||||
targetKey: key,
|
||||
})
|
||||
} else {
|
||||
next.add(key)
|
||||
next = new Set(selectedRows)
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
}
|
||||
selectionAnchorRef.current = next.size > 0 ? key : null
|
||||
setSelectedRows(next)
|
||||
if (next.size > 0) {
|
||||
setSelectedRow(null)
|
||||
@@ -212,16 +226,23 @@ export const LogTable = ({
|
||||
return (
|
||||
<div
|
||||
className="absolute group inset-0 flex justify-center px-2 items-center cursor-pointer"
|
||||
// Prevent a shift-click from starting a browser text selection across rows
|
||||
onMouseDown={(e) => {
|
||||
if (e.shiftKey) e.preventDefault()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
toggle(e.shiftKey)
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
className="group-hover:border-foreground-muted"
|
||||
checked={selectedRows.has(key)}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
onCheckedChange={toggle}
|
||||
// use onClick instead of onCheckedChange so the shift key is available for range selection
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
toggle(e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -292,6 +313,7 @@ export const LogTable = ({
|
||||
(row: LogData) => {
|
||||
// Regular single click — clear multi-select, open side panel
|
||||
setSelectedRows(new Set())
|
||||
selectionAnchorRef.current = null
|
||||
setSelectedRow(row)
|
||||
onSelectedLogChange?.(row)
|
||||
},
|
||||
@@ -367,6 +389,7 @@ export const LogTable = ({
|
||||
() => {
|
||||
if (selectedRows.size === logDataRows.length) {
|
||||
setSelectedRows(new Set())
|
||||
selectionAnchorRef.current = null
|
||||
} else {
|
||||
setSelectedRows(new Set(logDataRows.map((row) => getRowKey(row))))
|
||||
setSelectedRow(null)
|
||||
@@ -387,6 +410,7 @@ export const LogTable = ({
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
selectionAnchorRef.current = next.size > 0 ? key : null
|
||||
setSelectedRows(next)
|
||||
},
|
||||
{ enabled: selectedRow !== null }
|
||||
@@ -405,6 +429,7 @@ export const LogTable = ({
|
||||
SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
|
||||
() => {
|
||||
setSelectedRows(new Set())
|
||||
selectionAnchorRef.current = null
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
},
|
||||
{ enabled: !selectionOpen && selectedRows.size > 0 }
|
||||
@@ -427,6 +452,7 @@ export const LogTable = ({
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setSelectedRows(new Set())
|
||||
selectionAnchorRef.current = null
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
@@ -583,6 +609,7 @@ export const LogTable = ({
|
||||
sqlQuery={sqlQuery}
|
||||
onClear={() => {
|
||||
setSelectedRows(new Set())
|
||||
selectionAnchorRef.current = null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
genChartQuery,
|
||||
genDefaultQuery,
|
||||
getAuthLogSeverity,
|
||||
getShiftClickSelection,
|
||||
parseMultigresEventMessage,
|
||||
} from './Logs.utils'
|
||||
|
||||
@@ -351,4 +352,120 @@ describe('Logs.utils', () => {
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
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('toggles just the anchor when it is also the target', () => {
|
||||
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('removes the range when it is already fully selected, keeping keys outside it', () => {
|
||||
const result = getShiftClickSelection({
|
||||
orderedKeys,
|
||||
selectedKeys: new Set(['a', 'b', 'c', 'd']),
|
||||
anchorKey: 'b',
|
||||
targetKey: 'd',
|
||||
})
|
||||
expect([...result].sort()).toEqual(['a'])
|
||||
})
|
||||
|
||||
test('selects the whole range when it is only partially selected', () => {
|
||||
const result = getShiftClickSelection({
|
||||
orderedKeys,
|
||||
selectedKeys: new Set(['c']),
|
||||
anchorKey: 'b',
|
||||
targetKey: 'd',
|
||||
})
|
||||
expect([...result].sort()).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -957,3 +957,52 @@ export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?:
|
||||
'\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the next multi-select set after a shift-click on `targetKey`, extending
|
||||
* the selection from `anchorKey` (the last row the user clicked). Every key between
|
||||
* anchor and target (inclusive, in `orderedKeys` order) is added. If the whole range is
|
||||
* already selected, the range is removed instead. Falls back to a plain toggle of
|
||||
* `targetKey` when there is no usable anchor (null, or no longer in `orderedKeys`).
|
||||
*/
|
||||
export function getShiftClickSelection({
|
||||
orderedKeys,
|
||||
selectedKeys,
|
||||
anchorKey,
|
||||
targetKey,
|
||||
}: {
|
||||
orderedKeys: string[]
|
||||
selectedKeys: Set<string>
|
||||
anchorKey: string | null
|
||||
targetKey: string
|
||||
}): Set<string> {
|
||||
const next = new Set(selectedKeys)
|
||||
|
||||
const anchorIndex = anchorKey === null ? -1 : orderedKeys.indexOf(anchorKey)
|
||||
const targetIndex = orderedKeys.indexOf(targetKey)
|
||||
const hasUsableAnchor = anchorIndex !== -1 && targetIndex !== -1
|
||||
|
||||
if (!hasUsableAnchor) {
|
||||
if (next.has(targetKey)) {
|
||||
next.delete(targetKey)
|
||||
} else {
|
||||
next.add(targetKey)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex)
|
||||
const endIndex = Math.max(anchorIndex, targetIndex)
|
||||
const rangeKeys = orderedKeys.slice(startIndex, endIndex + 1)
|
||||
const isRangeFullySelected = rangeKeys.every((key) => selectedKeys.has(key))
|
||||
|
||||
rangeKeys.forEach((key) => {
|
||||
if (isRangeFullySelected) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.add(key)
|
||||
}
|
||||
})
|
||||
|
||||
return next
|
||||
}
|
||||
@@ -307,3 +307,80 @@ test('custom error message: Resources exceeded during query execution', async ()
|
||||
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())
|
||||
})
|
||||
Reference in new issue
Block a user