diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx index 86970ab91b3..b56a4b991cc 100644 --- a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -2,7 +2,7 @@ import { type FetchNextPageOptions } from '@tanstack/react-query' import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import { LoaderCircle } from 'lucide-react' -import { Fragment, ReactNode, UIEvent, useCallback, useRef } from 'react' +import { Fragment, KeyboardEvent, MouseEvent, ReactNode, UIEvent, useCallback, useRef } from 'react' import { Button, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' @@ -47,7 +47,8 @@ export function DataTableInfinite({ errorSubject = 'Failed to retrieve data', }: DataTableInfiniteProps) { const tableRef = useRef(null) - const { table, error, isError, isLoading, isFetching, openRowId, setOpenRowId } = useDataTable() + const { table, error, isError, isLoading, isFetching, openRowId, setOpenRowId, onSelectRow } = + useDataTable() const headerGroups = table.getHeaderGroups() const headers = headerGroups[0].headers @@ -141,13 +142,15 @@ export function DataTableInfinite({ > {rows.length ? ( rows.map((row) => ( - // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 setOpenRowId(row.id === openRowId ? undefined : row.id)} + selected={onSelectRow ? row.getIsSelected() : row.id === openRowId} + onSelect={(event) => { + if (onSelectRow) onSelectRow(row.id, event) + else setOpenRowId(row.id === openRowId ? undefined : row.id) + }} /> )) ) : isLoading ? ( @@ -274,7 +277,7 @@ function DataTableRow({ row: Row table: TTable selected?: boolean - onSelect: () => void + onSelect: (event: MouseEvent | KeyboardEvent) => void }) { const rowClassName = cn('group/row', (table.options.meta as any)?.getRowClassName?.(row)) const cells = row.getVisibleCells() @@ -284,14 +287,19 @@ function DataTableRow({ id={row.id} tabIndex={0} data-state={selected && 'selected'} + aria-selected={!!selected} onClick={onSelect} + onMouseDown={(event) => { + if (event.shiftKey) event.preventDefault() + }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - onSelect() + onSelect(event) } }} - className={cn(TableRowClassName, rowClassName)} + className={cn(TableRowClassName, 'cursor-pointer', rowClassName)} > {cells.map((cell) => { const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx index 8590708c99c..91007cfcee2 100644 --- a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx +++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx @@ -10,6 +10,7 @@ import type { import { createContext, ReactNode, useContext, useMemo } from 'react' import { DataTableFilterField } from '../DataTable.types' +import { RowSelectionModifiers } from '../rowSelection.utils' import { ResponseError } from '@/types' // REMINDER: read about how to move controlled state out of the useReactTable hook @@ -26,6 +27,7 @@ interface DataTableStateContextType { searchParameters: TSearchParams openRowId: string | undefined setOpenRowId: (id: string | undefined) => void + onSelectRow?: (id: string, modifiers?: RowSelectionModifiers) => void } interface DataTableBaseContextType { diff --git a/apps/studio/components/ui/DataTable/rowSelection.utils.test.ts b/apps/studio/components/ui/DataTable/rowSelection.utils.test.ts new file mode 100644 index 00000000000..46c5db33c61 --- /dev/null +++ b/apps/studio/components/ui/DataTable/rowSelection.utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { selectTableRow } from './rowSelection.utils' + +const ids = ['a', 'b', 'c', 'd', 'e'] + +describe('selectTableRow', () => { + it('replaces selection on plain click, including an already selected row', () => { + const selected = selectTableRow({ selected: { a: true, c: true } }, ids, 'c') + expect(selected).toEqual({ selected: { c: true }, anchorId: 'c', activeId: 'c' }) + }) + + it('clears selection on plain click of the only selected row', () => { + const selected = selectTableRow({ selected: {} }, ids, 'c') + expect(selectTableRow(selected, ids, 'c')).toEqual({ selected: {} }) + }) + + it.each([{ metaKey: true }, { ctrlKey: true }, { toggle: true }])( + 'toggles individual rows with %o', + (modifiers) => { + const start = selectTableRow({ selected: {} }, ids, 'a') + const added = selectTableRow(start, ids, 'c', modifiers) + expect(added.selected).toEqual({ a: true, c: true }) + expect(selectTableRow(added, ids, 'a', modifiers).selected).toEqual({ c: true }) + expect(selectTableRow(added, ids, 'c', modifiers).anchorId).toBe('c') + } + ) + + it('extends, shrinks, and reverses a range around a fixed anchor', () => { + const start = selectTableRow({ selected: {} }, ids, 'c') + const down = selectTableRow(start, ids, 'e', { shiftKey: true }) + expect(down.selected).toEqual({ c: true, d: true, e: true }) + const back = selectTableRow(down, ids, 'd', { shiftKey: true }) + expect(back.selected).toEqual({ c: true, d: true }) + expect(selectTableRow(back, ids, 'a', { shiftKey: true }).selected).toEqual({ + a: true, + b: true, + c: true, + }) + expect(back.anchorId).toBe('c') + }) + + it('keeps a range selected when shift-clicking it again', () => { + const start = selectTableRow({ selected: {} }, ids, 'a') + const range = selectTableRow(start, ids, 'c', { shiftKey: true }) + expect(selectTableRow(range, ids, 'c', { shiftKey: true })).toEqual(range) + }) + + it.each([{ metaKey: true }, { ctrlKey: true }])( + 'adds a range to disjoint selection with %o', + (modifiers) => { + const start = { selected: { a: true, d: true }, anchorId: 'd' } + expect(selectTableRow(start, ids, 'e', { shiftKey: true, ...modifiers }).selected).toEqual({ + a: true, + d: true, + e: true, + }) + } + ) + + it('uses the current display order', () => { + const start = { selected: { a: true }, anchorId: 'a' } + expect( + selectTableRow(start, ['e', 'a', 'c', 'b', 'd'], 'b', { shiftKey: true }).selected + ).toEqual({ a: true, c: true, b: true }) + }) + + it.each([undefined, 'missing'])('starts a fresh range when the anchor is %s', (anchorId) => { + expect( + selectTableRow({ selected: { a: true }, anchorId }, ids, 'c', { shiftKey: true }) + ).toEqual({ selected: { c: true }, anchorId: 'c', activeId: 'c' }) + }) + + it('ignores missing targets and empty results', () => { + const state = { selected: { a: true } } + expect(selectTableRow(state, ids, 'missing')).toBe(state) + expect(selectTableRow(state, [], 'a')).toBe(state) + }) +}) diff --git a/apps/studio/components/ui/DataTable/rowSelection.utils.ts b/apps/studio/components/ui/DataTable/rowSelection.utils.ts new file mode 100644 index 00000000000..0d9a209024c --- /dev/null +++ b/apps/studio/components/ui/DataTable/rowSelection.utils.ts @@ -0,0 +1,53 @@ +import type { RowSelectionState } from '@tanstack/react-table' + +export interface RowSelectionModifiers { + shiftKey?: boolean + metaKey?: boolean + ctrlKey?: boolean + toggle?: boolean +} + +export interface TableRowSelection { + selected: RowSelectionState + anchorId?: string + activeId?: string +} + +export function selectTableRow( + state: TableRowSelection, + orderedIds: string[], + targetId: string, + modifiers: RowSelectionModifiers = {} +): TableRowSelection { + const targetIndex = orderedIds.indexOf(targetId) + if (targetIndex === -1) return state + + const anchorIndex = state.anchorId ? orderedIds.indexOf(state.anchorId) : -1 + const isAdditive = modifiers.metaKey || modifiers.ctrlKey + let selected: RowSelectionState = {} + + if (modifiers.shiftKey && anchorIndex !== -1) { + selected = isAdditive ? { ...state.selected } : {} + for (const id of orderedIds.slice( + Math.min(anchorIndex, targetIndex), + Math.max(anchorIndex, targetIndex) + 1 + )) { + selected[id] = true + } + } else if (isAdditive || modifiers.toggle) { + selected = { ...state.selected } + if (selected[targetId]) delete selected[targetId] + else selected[targetId] = true + } else { + // Plain click on the only selected row clears the selection. + const selectedIds = Object.keys(state.selected).filter((id) => state.selected[id]) + if (selectedIds.length === 1 && selectedIds[0] === targetId) return { selected: {} } + selected[targetId] = true + } + + return { + selected, + anchorId: modifiers.shiftKey && anchorIndex !== -1 ? state.anchorId : targetId, + activeId: targetId, + } +} diff --git a/apps/studio/components/ui/DataTable/useTableRowSelection.test.tsx b/apps/studio/components/ui/DataTable/useTableRowSelection.test.tsx new file mode 100644 index 00000000000..96544ee8160 --- /dev/null +++ b/apps/studio/components/ui/DataTable/useTableRowSelection.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { useTableRowSelection } from './useTableRowSelection' + +describe('useTableRowSelection', () => { + it('restores a linked log and resets when filters or project change', () => { + const { result, rerender } = renderHook( + ({ scope }) => useTableRowSelection({ scope, initialId: 'b' }), + { initialProps: { scope: 'project-1:filters-1' } } + ) + expect(result.current.selection.selected).toEqual({ b: true }) + act(() => result.current.selectRow(['a', 'b', 'c'], 'c', { shiftKey: true })) + expect(result.current.selection.selected).toEqual({ b: true, c: true }) + rerender({ scope: 'project-1:filters-1' }) + expect(result.current.selection.selected).toEqual({ b: true, c: true }) + rerender({ scope: 'project-1:filters-2' }) + expect(result.current.selection).toEqual({ selected: {} }) + act(() => result.current.selectRow(['a'], 'a')) + rerender({ scope: 'project-2:filters-2' }) + expect(result.current.selection).toEqual({ selected: {} }) + }) + + it('clears the anchor and selection when closing the pane', () => { + const { result } = renderHook(() => useTableRowSelection({ scope: 'logs', initialId: 'a' })) + act(() => result.current.clearSelection()) + expect(result.current.selection).toEqual({ selected: {} }) + }) +}) diff --git a/apps/studio/components/ui/DataTable/useTableRowSelection.ts b/apps/studio/components/ui/DataTable/useTableRowSelection.ts new file mode 100644 index 00000000000..a01c2bd1d29 --- /dev/null +++ b/apps/studio/components/ui/DataTable/useTableRowSelection.ts @@ -0,0 +1,25 @@ +import { useState } from 'react' + +import { RowSelectionModifiers, selectTableRow, TableRowSelection } from './rowSelection.utils' + +export function useTableRowSelection({ scope, initialId }: { scope: string; initialId?: string }) { + const [selectionScope, setSelectionScope] = useState(scope) + const [selection, setSelection] = useState(() => ({ + selected: initialId ? { [initialId]: true } : {}, + anchorId: initialId, + activeId: initialId, + })) + + // A new project/filter/sort starts a new selection; paging and live updates do not. + if (selectionScope !== scope) { + setSelectionScope(scope) + setSelection({ selected: {} }) + } + + const selectRow = (orderedIds: string[], id: string, modifiers?: RowSelectionModifiers) => + setSelection((previous) => selectTableRow(previous, orderedIds, id, modifiers)) + + const clearSelection = () => setSelection({ selected: {} }) + + return { selection, selectRow, clearSelection } +}