diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 4a23d3783b1..52826144ea3 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -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(null) const [selectedRows, setSelectedRows] = useState>(new Set()) + // Last row key the user toggled, used as the start of a shift-click range + const selectionAnchorRef = useRef(null) const [copiedFormat, setCopiedFormat] = useState(null) const triggerRef = useRef(null) const [activeRow, setActiveRow] = useState(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 + 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 (
{ + if (e.shiftKey) e.preventDefault() + }} onClick={(e) => { e.stopPropagation() - toggle() + toggle(e.shiftKey) }} > 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) + }} />
) @@ -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 }} /> diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts index 6091e922362..5c3820c4ea7 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts @@ -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) + }) + }) }) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 68c318dca37..72e98b9487f 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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 + anchorKey: string | null + targetKey: string +}): Set { + 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 +} diff --git a/apps/studio/tests/features/logs/LogTable.test.tsx b/apps/studio/tests/features/logs/LogTable.test.tsx index d44212c9390..27d9f1541ef 100644 --- a/apps/studio/tests/features/logs/LogTable.test.tsx +++ b/apps/studio/tests/features/logs/LogTable.test.tsx @@ -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, 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() + + 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() + + 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() + + 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() + + 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()) +})