From eb738d2b84c421e9f2aeff7c0c16c7ea37b798ba Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 24 Sep 2026 08:56:21 +1000 Subject: [PATCH] feat(studio): open copy and filter actions from log detail rows (#50686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image 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) ## 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. Co-authored-by: Claude Opus 5 --- .../components/PostgresFlowDetail.tsx | 11 +-- .../ServiceFlow/components/shared/Block.tsx | 10 +-- .../components/shared/DetailRow.test.tsx | 61 +++++++++++++ .../components/shared/DetailRow.tsx | 87 ++++++------------- .../components/shared/DetailSection.tsx | 9 +- .../UnifiedLogs/components/LogFieldRow.tsx | 72 +++++++++++++++ .../ui/DataTable/DataTableSheetRowAction.tsx | 87 +++++++++---------- 7 files changed, 213 insertions(+), 124 deletions(-) create mode 100644 apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.test.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/LogFieldRow.tsx diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx index 7e2dca023ea..6a825baccff 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx @@ -72,16 +72,17 @@ export const PostgresFlowDetail = memo(function PostgresFlowDetail({
- + - + {postgresPrimaryFields.map((field) => ( - + - + {postgresDetailsFields.map((field) => ( {severity} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx index 7bc210c075b..c59faec487c 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx @@ -72,7 +72,7 @@ export function createBlock(config: BlockConfig) { return ( <> - + - + {config.primaryFields?.map((field) => ( { return ( - - + + - + {section.fields.map((field) => ( + 'GET' }} + value="GET" + level="success" + table={table} + filterFields={[{ value: 'method', label: 'Method', type: 'checkbox' }]} + /> + + {JSON.stringify(table.getState().columnFilters)} + + + ) +} + +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( + 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() + 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"]' + ) + }) +}) diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx index ddeecd15bcc..ff26d1f8bdd 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailRow.tsx @@ -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 = ( - - {label} - - ) - - const valueEl = isLoading ? ( - - ) : ( - - ) - - 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 ( -
-
- {labelEl} - {isFilterable && resolvedFilterValue !== undefined && ( - - )} -
-
- {valueEl} - {!isEmpty && ( - -
-
+ + {isLoading ? ( + + ) : ( + + )} + ) } @@ -102,7 +65,7 @@ interface FieldValueProps { const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode => { if (value === null || value === undefined || value === '') { - return — + return — } if (config.id === 'status') { @@ -110,7 +73,7 @@ const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode ) } @@ -119,7 +82,7 @@ const FieldValue = ({ config, value, wrap, level }: FieldValueProps): ReactNode return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx index d5df7bae8b7..ee0e849ea74 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx @@ -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) => (
- {topDivider && ( - - )}
{Icon ? ( ) : ( )} - - {title} - + {title}
{summary !== undefined && summary !== null && summary !== '' ? ( {summary} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogFieldRow.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogFieldRow.tsx new file mode 100644 index 00000000000..79284e02377 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogFieldRow.tsx @@ -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 { + 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['value'] + filterFields: DataTableFilterField[] + table?: Table + 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({ + label, + value, + children, + fieldValue, + filterFields, + table, + disabled, + truncateLabel = false, + alignOffset = 16, + className, +}: LogFieldRowProps) { + return ( + +
+ + {label} + {' '} + + {children ?? value} + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx index 207f4dc9687..f5f71dbdf94 100644 --- a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx +++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx @@ -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 + table?: Table label?: string + alignOffset?: number } export function DataTableSheetRowAction>({ @@ -48,10 +48,10 @@ export function DataTableSheetRowAction) { - const { copy } = useCopyToClipboard() const [open, setOpen] = useState(false) /** @@ -72,7 +72,7 @@ export function DataTableSheetRowAction 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 - Add as filter for {column?.id} + Add filter ) case 'input': @@ -111,7 +111,7 @@ export function DataTableSheetRowAction - Add as filter for {column?.id} + Add filter ) case 'slider': @@ -182,42 +182,41 @@ export function DataTableSheetRowAction - { - 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 ( + + { + 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} + + + + {!!field && !!column && ( + <> + {renderOptions()} + + + )} + + copyToClipboard(String(value))} + className="flex items-center gap-2" > - {children} - - - - {renderOptions()} - - - - copy(String(value), { timeout: 1000 })} - className="flex items-center gap-2" - > - - Copy {label} - - - - ) - } - - return + + Copy {label} + + + + ) }