fix: copy cell for record peak portal on forgien relationship (#44144)

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

YES/NO

## What kind of change does this PR introduce?

fixes: #37454 

The context menu on the FK row is a lie, its just exposing the cell
underneath. Ideally this should have its own context menu, right now we
only support copy operations.

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ali WaseemandJoshen Lim authored and Matt Rossman committed 2026-03-30 16:03:00 -04:00
1 parent 91cffdafd8
commit a4d32bdd94
6 files changed
+150 -20

No files matched your search

@@ -0,0 +1,41 @@
import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
import { Copy } from 'lucide-react'
import { PropsWithChildren } from 'react'
import { toast } from 'sonner'
import {
ContextMenu_Shadcn_,
ContextMenuItem_Shadcn_,
ContextMenuTrigger_Shadcn_,
copyToClipboard,
} from 'ui'
import { formatClipboardValue } from '../../utils/common'
/**
* [Joshen] RowRenderer should be using this so that we can deprecate `react-contextify`
* We can probably extend this for all the other actions like edit, delete, etc
*/
export const CellContextMenuWrapper = ({
value,
children,
}: PropsWithChildren<{ value: string | number | boolean | object | null }>) => {
const onCopyCellContent = () => {
const text = formatClipboardValue(value)
copyToClipboard(text)
toast.success('Copied cell value to clipboard')
}
return (
<ContextMenu_Shadcn_>
<ContextMenuTrigger_Shadcn_ className="flex items-center w-full">
{children}
</ContextMenuTrigger_Shadcn_>
<ContextMenuContent>
<ContextMenuItem_Shadcn_ className="gap-x-2" onSelect={onCopyCellContent}>
<Copy size={12} />
<span className="text-xs">Copy cell</span>
</ContextMenuItem_Shadcn_>
</ContextMenuContent>
</ContextMenu_Shadcn_>
)
}
@@ -78,6 +78,7 @@ export const ForeignKeyFormatter = (props: Props) => {
<ButtonTooltip
type="default"
className="w-6 h-6"
aria-label="View referencing record"
icon={<ArrowRight />}
onClick={(e) => e.stopPropagation()}
tooltip={{ content: { side: 'bottom', text: 'View referencing record' } }}
@@ -90,6 +91,9 @@ export const ForeignKeyFormatter = (props: Props) => {
e.preventDefault()
e.stopPropagation()
}}
onContextMenu={(e) => {
e.stopPropagation()
}}
>
<ReferenceRecordPeek
table={targetTable}
@@ -2,6 +2,7 @@ import { PostgresTable } from '@supabase/postgres-meta'
import { keepPreviousData } from '@tanstack/react-query'
import { useParams } from 'common'
import { COLUMN_MIN_WIDTH } from 'components/grid/constants'
import type { SupaColumn, SupaRow } from 'components/grid/types'
import {
ESTIMATED_CHARACTER_PIXEL_WIDTH,
getColumnDefaultWidth,
@@ -11,19 +12,20 @@ import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Key } from 'lucide-react'
import { useMemo } from 'react'
import DataGrid, { Column } from 'react-data-grid'
import { useMemo, useRef } from 'react'
import DataGrid, { CalculatedColumn, Column } from 'react-data-grid'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { BinaryFormatter } from './BinaryFormatter'
import { CellContextMenuWrapper } from './CellContextMenuWrapper'
import { DefaultFormatter } from './DefaultFormatter'
import { JsonFormatter } from './JsonFormatter'
interface ReferenceRecordPeekProps {
table: PostgresTable
column: string
value: any
value: string | number | Record<string, unknown>
}
export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPeekProps) => {
@@ -47,6 +49,9 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
{ placeholderData: keepPreviousData }
)
const rows = useMemo(() => data?.rows ?? [], [data?.rows])
const selectedCellRef = useRef<{ idx: number; rowIdx: number } | null>(null)
const primaryKeys = useMemo(() => table.primary_keys.map((x) => x.name), [table.primary_keys])
const columns = useMemo(() => {
@@ -54,14 +59,14 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
const columnDefaultWidth = getColumnDefaultWidth({
dataType: column.data_type,
format: column.format,
} as any)
} as Pick<SupaColumn, 'dataType' | 'format'> as SupaColumn)
const columnWidthBasedOnName =
(column.name.length + column.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH
const columnWidth =
columnDefaultWidth < columnWidthBasedOnName ? columnWidthBasedOnName : columnDefaultWidth
const isPrimaryKey = primaryKeys.includes(column.name)
const res: Column<any> = {
const res: Column<SupaRow> = {
key: column.name,
name: column.name,
resizable: false,
@@ -84,11 +89,17 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
<span className="text-xs text-foreground-light font-normal">{column.format}</span>
</div>
),
renderCell: isBinaryColumn(column.data_type)
? BinaryFormatter
: isJsonColumn(column.data_type) && !isArrayColumn(column.data_type)
? JsonFormatter
: DefaultFormatter,
renderCell: (props) => (
<CellContextMenuWrapper value={props.row[props.column.key]}>
{isBinaryColumn(column.data_type) ? (
<BinaryFormatter {...props} />
) : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type) ? (
<JsonFormatter {...props} />
) : (
<DefaultFormatter {...props} />
)}
</CellContextMenuWrapper>
),
}
return res
})
@@ -106,7 +117,14 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
<DataGrid
className="h-32 rounded-b border-0"
columns={columns}
rows={data?.rows ?? []}
rows={rows}
onSelectedCellChange={(args: {
column: CalculatedColumn<SupaRow, unknown>
rowIdx: number
row: SupaRow
}) => {
selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx }
}}
onCellDoubleClick={(_, e) => {
e.preventDefault()
e.stopPropagation()
@@ -23,7 +23,7 @@ import type { GridProps, SupaRow } from '../../types'
import { isPendingAddRow, isPendingDeleteRow } from '../../types'
import { useOnRowsChange } from './Grid.utils'
import { GridError } from './GridError'
import RowRenderer from './RowRenderer'
import { RowRenderer } from './RowRenderer'
import { ResponseError } from '@/types'
const rowKeyGetter = (row: SupaRow) => {
@@ -1,11 +1,13 @@
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
import { SupaRow } from 'components/grid/types'
import type { Key } from 'react'
import { TriggerEvent, useContextMenu } from 'react-contexify'
import { RenderRowProps, Row } from 'react-data-grid'
import { ROW_CONTEXT_MENU_ID } from 'components/grid/constants'
import { SupaRow } from 'components/grid/types'
export default function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
/**
* TODO: Refactor to use CellContextMenuWrapper instead of react-contexify
*/
export function RowRenderer(key: Key, props: RenderRowProps<SupaRow>) {
const { show: showContextMenu } = useContextMenu()
function displayMenu(e: TriggerEvent) {
+69 -4
View File
@@ -1355,10 +1355,10 @@ testRunner('table editor', () => {
last_name text
)`
)
await query(
`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`,
['Alice', 'Smith']
)
await query(`INSERT INTO ${tableName} (first_name, last_name) VALUES ($1, $2)`, [
'Alice',
'Smith',
])
},
async () => {
await dropTable(tableName)
@@ -1521,4 +1521,69 @@ testRunner('table editor', () => {
exact: true,
})
})
test('copying cell content from referencing record peek copies the correct value', async ({
page,
ref,
}) => {
const targetTable = 'pw_fk_peek_target'
const sourceTable = 'pw_fk_peek_source'
await using _ = await withSetupCleanup(
async () => {
await query(`
CREATE TABLE IF NOT EXISTS ${targetTable} (
id bigint generated by default as identity primary key,
name text
)
`)
await query(`ALTER TABLE public.${targetTable} ENABLE ROW LEVEL SECURITY`)
await query(`INSERT INTO ${targetTable} (name) VALUES ('target_value')`)
await query(`
CREATE TABLE IF NOT EXISTS ${sourceTable} (
id bigint generated by default as identity primary key,
label text,
target_id bigint references ${targetTable}(id)
)
`)
await query(`ALTER TABLE public.${sourceTable} ENABLE ROW LEVEL SECURITY`)
await query(`INSERT INTO ${sourceTable} (label, target_id) VALUES ('source_label', 1)`)
},
async () => {
await dropTable(sourceTable)
await dropTable(targetTable)
}
)
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
await page.getByRole('button', { name: `View ${sourceTable}`, exact: true }).click()
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
await expect(page.getByRole('grid')).toBeVisible()
await expect(page.getByRole('gridcell', { name: 'source_label' })).toBeVisible()
await page.getByRole('button', { name: 'View referencing record' }).click()
const popover = page.getByText(`Referencing record from public.${targetTable}`)
await expect(popover).toBeVisible()
const popoverContent = page.locator('[data-radix-popper-content-wrapper]')
await expect(popoverContent.getByRole('gridcell', { name: 'target_value' })).toBeVisible()
// Right-click on the target_value cell inside the popover to open the peek context menu.
// Before the fix, this would trigger the main grid's context menu via React portal
// event bubbling, and "Copy cell" would copy the wrong value from the main grid.
await popoverContent.getByRole('gridcell', { name: 'target_value' }).click({ button: 'right' })
// The peek grid's own context menu should appear with "Copy cell"
await page.getByRole('menuitem', { name: 'Copy cell' }).click()
// Verify the correct referenced value was copied (not the main grid's FK value "1")
await expectClipboardValue({
page,
value: 'target_value',
exact: true,
})
})
})