feat(studio): open copy and filter actions from log detail rows (#50686)

<img width="1353" height="1046" alt="image"
src="https://github.com/user-attachments/assets/22c69de8-59ff-4318-9044-c222b01b6154"
/>


Stack 1/4 · next: #50687

## Problem

In the Unified Logs detail panel, each field row had a small kebab
button. Fields that couldn't be filtered fell back to a separate copy
button, so the actions weren't consistent. Filter labels also repeated
the column id ("Add as filter for method").

## Solution

- Adds `LogFieldRow`: the whole key/value row opens the actions menu on
click or Enter. Rows you can filter show filter + copy, and every other
row shows copy only.
- `DataTableSheetRowAction` always renders the dropdown now. `table` is
optional, copy is always available, labels read "Add filter", and the
menu aligns to the row.
- `DetailRow` is rebuilt on `LogFieldRow`. The section styling is
refreshed: bordered collapsibles, no zebra striping, and
`heading-default` section titles. The `topDivider` prop is removed.

## Review instructions

1. Open Unified Logs and select a Postgres or PostgREST log.
2. Click a filterable row (such as method or status). You should see
"Add filter" and "Copy …" in one menu.
3. Tab to a row that can't be filtered and press Enter. You should see a
copy-only menu.

## Checklist

- [ ] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)


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

## Summary by CodeRabbit

* **New Features**
  * Added consistent copy and filter actions to log detail rows.
* Added keyboard-accessible row actions, including “Add filter” for
filterable values.

* **UI Improvements**
* Updated log detail sections with clearer borders, headings, hover
states, spacing, and typography.
* Simplified detail row presentation and improved value wrapping and
readability.

* **Bug Fixes**
  * Improved handling of empty and filterable log values in detail rows.

* **Tests**
* Added coverage for copying values and applying filters from detail
rows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Saxon FletcherandClaude Opus 5 authored and GitHub committed 2026-09-24 08:56:21 +10:00
1 parent d51ed9f451
commit eb738d2b84
7 files changed
+213 -124

No files matched your search

@@ -72,16 +72,17 @@ export const PostgresFlowDetail = memo(function PostgresFlowDetail({
<div>
<DetailSectionHeader
title="Request started"
className="border-b"
icon={Clock}
summary={formattedTime ?? undefined}
/>
<Collapsible defaultOpen>
<Collapsible defaultOpen className="border-b">
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
<DetailSectionHeader title="Postgres" icon={Database} />
<ChevronDown className="transition-transform duration-200" strokeWidth={1.5} size={14} />
</CollapsibleTrigger>
<CollapsibleContent className="[&>*:nth-child(odd)]:bg-surface-100/50">
<CollapsibleContent>
{postgresPrimaryFields.map((field) => (
<FieldDetailRow
key={field.id}
@@ -96,12 +97,12 @@ export const PostgresFlowDetail = memo(function PostgresFlowDetail({
</CollapsibleContent>
</Collapsible>
<Collapsible defaultOpen>
<Collapsible defaultOpen className="border-b">
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
<DetailSectionHeader title="Connection & Session Details" icon={Cable} />
<ChevronDown className="transition-transform duration-200" strokeWidth={1.5} size={14} />
</CollapsibleTrigger>
<CollapsibleContent className="[&>*:nth-child(odd)]:bg-surface-100/50">
<CollapsibleContent>
{postgresDetailsFields.map((field) => (
<FieldDetailRow
key={field.id}
@@ -119,7 +120,7 @@ export const PostgresFlowDetail = memo(function PostgresFlowDetail({
<DetailSectionHeader
title="Operation result"
icon={Clock}
topDivider
className="border-b"
summary={
severity ? (
<span className="font-mono text-sm uppercase text-foreground">{severity}</span>
@@ -72,7 +72,7 @@ export function createBlock(config: BlockConfig) {
return (
<>
<Collapsible defaultOpen>
<Collapsible defaultOpen className="border-b">
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
<DetailSectionHeader title={config.title} icon={config.icon} />
<ChevronDown
@@ -81,7 +81,7 @@ export function createBlock(config: BlockConfig) {
size={14}
/>
</CollapsibleTrigger>
<CollapsibleContent className="[&>*:nth-child(odd)]:bg-surface-100/50">
<CollapsibleContent>
{config.primaryFields?.map((field) => (
<FieldRow
key={field.id}
@@ -113,8 +113,8 @@ export function createBlock(config: BlockConfig) {
{data.log_type !== 'auth' &&
otherSections.map((section) => {
return (
<Collapsible key={section.title}>
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180!">
<Collapsible key={section.title} className="border-b">
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
<DetailSectionHeader title={section.title} icon={section.icon} />
<ChevronDown
className="transition-transform duration-200"
@@ -122,7 +122,7 @@ export function createBlock(config: BlockConfig) {
size={14}
/>
</CollapsibleTrigger>
<CollapsibleContent className="[&>*:nth-child(odd)]:bg-surface-100/50">
<CollapsibleContent>
{section.fields.map((field) => (
<FieldRow
key={field.id}
@@ -0,0 +1,61 @@
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { DetailRow } from './DetailRow'
function FilterableDetailRow() {
const table = useReactTable({
data: [{ method: 'GET' }],
columns: [{ accessorKey: 'method' }],
getCoreRowModel: getCoreRowModel(),
})
return (
<>
<DetailRow
config={{ id: 'method', label: 'Method', getValue: () => 'GET' }}
value="GET"
level="success"
table={table}
filterFields={[{ value: 'method', label: 'Method', type: 'checkbox' }]}
/>
<output aria-label="Selected filters">
{JSON.stringify(table.getState().columnFilters)}
</output>
</>
)
}
describe('overview detail row actions', () => {
it('opens copy-only actions from the entire row with the keyboard', async () => {
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
render(
<DetailRow
config={{ id: 'count', label: 'Count', getValue: () => 0 }}
value={0}
level={null}
filterFields={[]}
/>
)
expect(screen.queryByRole('button', { name: 'More options' })).not.toBeInTheDocument()
const row = screen.getByRole('button')
row.focus()
await user.keyboard('{Enter}')
expect(screen.queryByRole('menuitem', { name: /filter/i })).not.toBeInTheDocument()
await user.click(screen.getByRole('menuitem', { name: 'Copy Count' }))
expect(copy).toHaveBeenCalledWith('0')
})
it('offers filter and copy actions in the same menu for filterable rows', async () => {
const user = userEvent.setup()
render(<FilterableDetailRow />)
await user.click(screen.getByText('GET'))
expect(screen.getByRole('menuitem', { name: 'Copy Method' })).toBeVisible()
await user.click(screen.getByRole('menuitem', { name: 'Add filter' }))
expect(screen.getByRole('status', { name: 'Selected filters' })).toHaveTextContent(
'"values":["GET"]'
)
})
})
@@ -1,13 +1,12 @@
import { Table } from '@tanstack/react-table'
import { Filter, MoreVertical } from 'lucide-react'
import { ReactNode } from 'react'
import { Button, cn, Skeleton } from 'ui'
import { cn, Skeleton } from 'ui'
import { LogFieldRow } from '../../../components/LogFieldRow'
import { type ColumnSchema } from '../../../UnifiedLogs.schema'
import { type BlockFieldConfig } from '../../types'
import { DataTableFilterField } from '@/components/ui/DataTable/DataTable.types'
import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode'
import { DataTableSheetRowAction } from '@/components/ui/DataTable/DataTableSheetRowAction'
interface DetailRowProps {
config: BlockFieldConfig
@@ -32,64 +31,28 @@ export const DetailRow = ({
isLoading,
}: DetailRowProps) => {
const { id: filterId, label, wrap } = config
const isFilterable =
!!filterId && !!filterFields?.some((f) => f.value === filterId) && !!table && !isLoading
const resolvedFilterValue = filterValue ?? undefined
const isEmpty = value === null || value === undefined || value === ''
const labelEl = (
<span
className={cn(
'flex items-center gap-2 text-xs uppercase tracking-wide text-foreground-lighter',
wrap ? 'shrink-0' : 'min-w-0 truncate'
)}
>
<span className={cn('font-mono', !wrap && 'truncate')}>{label}</span>
</span>
)
const valueEl = isLoading ? (
<Skeleton className="h-4 w-24" />
) : (
<FieldValue config={config} value={value} wrap={config.wrap} level={level ?? undefined} />
)
const rowClass = cn(
'flex items-start justify-between items-center gap-x-10 px-4',
wrap ? 'min-h-9 py-0' : 'h-9'
)
const resolvedValue = filterValue ?? value ?? ''
const hasValue = value !== null && value !== undefined && value !== ''
return (
<div className={cn(rowClass, 'rounded-none group w-full')}>
<div className="flex items-center gap-x-2 pl-[22px]">
{labelEl}
{isFilterable && resolvedFilterValue !== undefined && (
<Filter size={12} className="text-foreground-lighter" />
)}
</div>
<div
className={cn('flex items-center gap-x-2 min-w-0 flex-1 justify-end', isEmpty && 'pr-2')}
>
{valueEl}
{!isEmpty && (
<DataTableSheetRowAction
fieldValue={filterId}
filterFields={filterFields}
value={resolvedFilterValue ?? ''}
table={table!}
label={label}
>
<Button
aria-label="More options"
variant="text"
className="px-1"
icon={<MoreVertical />}
/>
</DataTableSheetRowAction>
)}
</div>
</div>
<LogFieldRow
label={label}
value={resolvedValue}
fieldValue={hasValue ? filterId : undefined}
filterFields={filterFields}
table={table}
disabled={isLoading}
truncateLabel={!wrap}
alignOffset={38}
// Indent past the section header icon
className="pl-[38px]"
>
{isLoading ? (
<Skeleton className="my-0.5 h-4 w-24" />
) : (
<FieldValue config={config} value={value} wrap={wrap} level={level ?? undefined} />
)}
</LogFieldRow>
)
}
@@ -102,7 +65,7 @@ interface FieldValueProps {
const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode => {
if (value === null || value === undefined || value === '') {
return <span className="font-mono text-xs text-foreground-muted">—</span>
return <span className="font-mono text-sm leading-5 text-foreground-muted">—</span>
}
if (config.id === 'status') {
@@ -110,7 +73,7 @@ const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode
<DataTableColumnStatusCode
value={value as string | number}
level={level}
className="text-xs"
className="text-sm leading-5"
/>
)
}
@@ -119,7 +82,7 @@ const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode
return (
<span
className={cn(
'font-mono text-xs text-foreground min-w-0',
'font-mono text-sm leading-5 text-foreground min-w-0',
wrap ? 'break-all text-right' : 'truncate text-right'
)}
>
@@ -11,7 +11,6 @@ interface DetailSectionHeaderProps {
icon?: IconComponent
summary?: ReactNode
className?: string
topDivider?: boolean
}
export const DetailSectionHeader = ({
@@ -19,21 +18,15 @@ export const DetailSectionHeader = ({
icon: Icon,
summary,
className,
topDivider,
}: DetailSectionHeaderProps) => (
<div className={cn('relative flex h-9 items-center justify-between gap-3 px-4', className)}>
{topDivider && (
<span className="absolute inset-x-4 top-0 border-t border-dashed border-border-strong" />
)}
<div className="flex min-w-0 items-center gap-2">
{Icon ? (
<Icon size={14} strokeWidth={1.5} className="shrink-0 text-foreground-lighter" />
) : (
<span className="w-3.5 shrink-0" aria-hidden />
)}
<span className="truncate text-xs uppercase tracking-wider text-foreground-light font-mono">
{title}
</span>
<span className="truncate heading-default text-foreground">{title}</span>
</div>
{summary !== undefined && summary !== null && summary !== '' ? (
<span className="truncate text-right font-mono text-xs text-foreground">{summary}</span>
@@ -0,0 +1,72 @@
import type { Table } from '@tanstack/react-table'
import type { ReactNode } from 'react'
import { cn } from 'ui'
import type { DataTableFilterField } from '@/components/ui/DataTable/DataTable.types'
import { DataTableSheetRowAction } from '@/components/ui/DataTable/DataTableSheetRowAction'
interface LogFieldRowProps<TData> {
label: string
/** Value to copy, and to filter by when `fieldValue` is set. */
value: string | number
/** Rendered value; defaults to `value`. */
children?: ReactNode
/** Filter field id; omit for copy-only rows. */
fieldValue?: DataTableFilterField<TData>['value']
filterFields: DataTableFilterField<TData>[]
table?: Table<TData>
disabled?: boolean
/** Truncate the label to one line instead of wrapping it. */
truncateLabel?: boolean
alignOffset?: number
className?: string
}
/** Key/value row for a log field, opening copy and filter actions on click. */
export function LogFieldRow<TData>({
label,
value,
children,
fieldValue,
filterFields,
table,
disabled,
truncateLabel = false,
alignOffset = 16,
className,
}: LogFieldRowProps<TData>) {
return (
<DataTableSheetRowAction
fieldValue={fieldValue}
filterFields={filterFields}
table={table}
value={value}
label={label}
disabled={disabled}
className="rounded-none"
alignOffset={alignOffset}
>
<div
role="button"
tabIndex={disabled ? -1 : 0}
className={cn(
'flex min-w-0 flex-1 cursor-pointer items-start justify-between gap-4 px-4 py-2',
'hover:bg-surface-200 data-[state=open]:bg-surface-200',
className
)}
>
<span
className={cn(
'min-w-0 flex-1 font-sans text-sm leading-5 text-foreground-lighter',
truncateLabel ? 'truncate' : 'break-all'
)}
>
{label}
</span>{' '}
<span className="flex min-w-0 flex-[2] justify-end text-right text-sm leading-5">
{children ?? value}
</span>
</div>
</DataTableSheetRowAction>
)
}
@@ -13,6 +13,7 @@ import {
import { ComponentPropsWithRef, useEffect, useState } from 'react'
import {
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
@@ -25,9 +26,7 @@ import {
isLogsFilterColumnValue,
type LogsColumnFilterValue,
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.filters'
import CopyButton from '@/components/ui/CopyButton'
import { DataTableFilterField } from '@/components/ui/DataTable/DataTable.types'
import { useCopyToClipboard } from '@/hooks/ui/useCopyToClipboard'
interface DataTableSheetRowActionProps<
TData,
@@ -36,8 +35,9 @@ interface DataTableSheetRowActionProps<
fieldValue?: TFields['value']
filterFields: TFields[]
value: string | number
table: Table<TData>
table?: Table<TData>
label?: string
alignOffset?: number
}
export function DataTableSheetRowAction<TData, TFields extends DataTableFilterField<TData>>({
@@ -48,10 +48,10 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
className,
table,
label,
alignOffset = 8,
onKeyDown,
...props
}: DataTableSheetRowActionProps<TData, TFields>) {
const { copy } = useCopyToClipboard()
const [open, setOpen] = useState(false)
/**
@@ -72,7 +72,7 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
const field = !!fieldValue ? filterFields.find((f) => f.value === fieldValue) : undefined
const column =
!!fieldValue && !!field
? table.getAllColumns().find((c) => c.id === fieldValue.toString())
? table?.getAllColumns().find((c) => c.id === fieldValue.toString())
: undefined
function renderOptions() {
@@ -96,7 +96,7 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
className="flex items-center gap-2"
>
<Filter size={12} />
Add as filter for {column?.id}
Add filter
</DropdownMenuItem>
)
case 'input':
@@ -111,7 +111,7 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
className="flex items-center gap-2"
>
<Filter size={12} />
Add as filter for {column?.id}
Add filter
</DropdownMenuItem>
)
case 'slider':
@@ -182,42 +182,41 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
}
}
if (!!field && !!column) {
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn('rounded-md', 'focus-ring', 'relative py-0', className)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
// REMINDER: default behavior is to open the dropdown menu
// But because we use it to navigate between rows, we need to prevent it
// and only use "Enter" to select the option
e.preventDefault()
}
onKeyDown?.(e)
}}
{...props}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
asChild
className={cn('focus-ring relative', className)}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
// REMINDER: default behavior is to open the dropdown menu
// But because we use it to navigate between rows, we need to prevent it
// and only use "Enter" to select the option
e.preventDefault()
}
onKeyDown?.(e)
}}
{...props}
>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" alignOffset={alignOffset} side="bottom" className="w-56">
{!!field && !!column && (
<>
{renderOptions()}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onSelect={() => copyToClipboard(String(value))}
className="flex items-center gap-2"
>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-56">
{renderOptions()}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => copy(String(value), { timeout: 1000 })}
className="flex items-center gap-2"
>
<Copy size={12} />
Copy {label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return <CopyButton iconOnly variant="text" text={String(value)} className="px-1" />
<Copy size={12} />
Copy {label}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}