diff --git a/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx b/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx
new file mode 100644
index 00000000000..b465ef79f93
--- /dev/null
+++ b/apps/studio/components/grid/components/formatter/CellContextMenuWrapper.tsx
@@ -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 (
+
+
+ {children}
+
+
+
+
+ Copy cell
+
+
+
+ )
+}
diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
index bfa8f2cab28..ef26a4d64ee 100644
--- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
+++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx
@@ -78,6 +78,7 @@ export const ForeignKeyFormatter = (props: Props) => {
}
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()
+ }}
>
}
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 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 = {
+ const res: Column = {
key: column.name,
name: column.name,
resizable: false,
@@ -84,11 +89,17 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
{column.format}
),
- renderCell: isBinaryColumn(column.data_type)
- ? BinaryFormatter
- : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type)
- ? JsonFormatter
- : DefaultFormatter,
+ renderCell: (props) => (
+
+ {isBinaryColumn(column.data_type) ? (
+
+ ) : isJsonColumn(column.data_type) && !isArrayColumn(column.data_type) ? (
+
+ ) : (
+
+ )}
+
+ ),
}
return res
})
@@ -106,7 +117,14 @@ export const ReferenceRecordPeek = ({ table, column, value }: ReferenceRecordPee
+ rowIdx: number
+ row: SupaRow
+ }) => {
+ selectedCellRef.current = { idx: args.column.idx, rowIdx: args.rowIdx }
+ }}
onCellDoubleClick={(_, e) => {
e.preventDefault()
e.stopPropagation()
diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx
index 048b0c8ccae..049a775d00e 100644
--- a/apps/studio/components/grid/components/grid/Grid.tsx
+++ b/apps/studio/components/grid/components/grid/Grid.tsx
@@ -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) => {
diff --git a/apps/studio/components/grid/components/grid/RowRenderer.tsx b/apps/studio/components/grid/components/grid/RowRenderer.tsx
index 05e082f7254..fed375a8d78 100644
--- a/apps/studio/components/grid/components/grid/RowRenderer.tsx
+++ b/apps/studio/components/grid/components/grid/RowRenderer.tsx
@@ -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) {
+/**
+ * TODO: Refactor to use CellContextMenuWrapper instead of react-contexify
+ */
+export function RowRenderer(key: Key, props: RenderRowProps) {
const { show: showContextMenu } = useContextMenu()
function displayMenu(e: TriggerEvent) {
diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts
index b6f7de847ff..c5e461bce13 100644
--- a/e2e/studio/features/table-editor.spec.ts
+++ b/e2e/studio/features/table-editor.spec.ts
@@ -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,
+ })
+ })
})