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, + }) + }) })