mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
d51ed9f451
commit
eb738d2b84
7 files changed
+213
-124
No files matched your search
+6
-5
@@ -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>
|
||||
|
||||
+5
-5
@@ -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}
|
||||
|
||||
+61
@@ -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"]'
|
||||
)
|
||||
})
|
||||
})
|
||||
+25
-62
@@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
+1
-8
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user