feat: Add table and column menus in schema visualiser (#43693)

## Problem

- The schema visualiser lacks editing capabilities which leads to a lot
of navigation (ediing tables, columns)
- ReactFlow prevents users from selecting table and column names (to
copy them). Diasbling drag and pan on those texts would make moving
items cumbersome
- Long table and column names are hidden and even hide other elements

## Solution

- Add menus for both tables and columns
- Truncate long names with ellipsis and add a tooltip
- Hide menus when exporting to png/svg

[Screen Recording 2026-03-12 at
10.10.08.webm](https://github.com/user-attachments/assets/b2780266-e874-41d1-ac82-7c2c4ba5abf2)
This commit is contained in:
Gildas Garcia
2026-03-16 22:14:44 +01:00
committed by GitHub
parent bc23782d0b
commit db14762aa6
6 changed files with 268 additions and 91 deletions

View File

@@ -1,22 +0,0 @@
import { createContext, useContext, type ReactNode } from 'react'
export type ColumnEditionContextType = {
onEditColumn: (tableId: number, columnId: string) => void
}
export const ColumnEditionContext = createContext<ColumnEditionContextType | null>(null)
export const ColumnEditionContextProvider = ({
children,
value,
}: {
children: ReactNode
value: ColumnEditionContextType
}) => <ColumnEditionContext.Provider value={value}>{children}</ColumnEditionContext.Provider>
export const useColumnEditionContext = () => {
const context = useContext(ColumnEditionContext)
if (!context)
throw new Error('useColumnEditionContext must be used inside a <ColumnEditionContextProvider>')
return context
}

View File

@@ -34,7 +34,7 @@ import {
import { Admonition } from 'ui-patterns/admonition'
import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor'
import { ColumnEditionContextProvider, ColumnEditionContextType } from './ColumnEditionContext'
import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
@@ -210,10 +210,20 @@ export const SchemaGraph = () => {
}
})
}
}, [isSuccessTables, isSuccessSchemas, tables, resolvedTheme])
}, [
isSuccessTables,
isSuccessSchemas,
tables,
reactFlowInstance,
ref,
resolvedTheme,
schemas,
selectedSchema,
])
const columnEditionContext = useMemo<ColumnEditionContextType>(
const schemaGraphPanelEditorContext = useMemo<SchemaGraphContextType>(
() => ({
isDownloading,
onEditColumn: (tableId, columnId) => {
const table = tables.find((table) => table.id === tableId)
if (!table || table.columns == null) return
@@ -224,8 +234,15 @@ export const SchemaGraph = () => {
setSelectedTable(table)
snap.onEditColumn(column)
},
onEditTable: (tableId) => {
const table = tables.find((table) => table.id === tableId)
if (!table || table.columns == null) return
setSelectedTable(table)
snap.onEditTable()
},
}),
[tables, snap]
[tables, snap, isDownloading]
)
return (
@@ -349,7 +366,7 @@ export const SchemaGraph = () => {
</Admonition>
</div>
) : (
<ColumnEditionContextProvider value={columnEditionContext}>
<SchemaGraphContextProvider value={schemaGraphPanelEditorContext}>
<div className="w-full h-full">
<ReactFlow
defaultNodes={[]}
@@ -382,7 +399,7 @@ export const SchemaGraph = () => {
<SchemaGraphLegend />
</ReactFlow>
</div>
</ColumnEditionContextProvider>
</SchemaGraphContextProvider>
)}
</>
)}

View File

@@ -0,0 +1,24 @@
import { createContext, useContext, type ReactNode } from 'react'
export type SchemaGraphContextType = {
isDownloading: boolean
onEditColumn: (tableId: number, columnId: string) => void
onEditTable: (tableId: number) => void
}
export const SchemaGraphContext = createContext<SchemaGraphContextType | null>(null)
export const SchemaGraphContextProvider = ({
children,
value,
}: {
children: ReactNode
value: SchemaGraphContextType
}) => <SchemaGraphContext.Provider value={value}>{children}</SchemaGraphContext.Provider>
export const useSchemaGraphContext = () => {
const context = useContext(SchemaGraphContext)
if (!context)
throw new Error('useSchemaGraphContext must be used inside a <SchemaGraphContextProvider>')
return context
}

View File

@@ -1,19 +1,35 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils'
import { TableEditor } from 'icons'
import {
Copy,
DiamondIcon,
Edit,
ExternalLink,
Fingerprint,
Hash,
InfoIcon,
Key,
MoreVertical,
Table2,
} from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Handle, NodeProps } from 'reactflow'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
Button,
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { useColumnEditionContext } from './ColumnEditionContext'
import { useSchemaGraphContext } from './SchemaGraphContext'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
// ReactFlow is scaling everything by the factor of 2
export const TABLE_NODE_WIDTH = 320
@@ -46,8 +62,13 @@ export const TableNode = ({
// Important styles is a nasty hack to use Handles (required for edges calculations), but do not show them in the UI.
// ref: https://github.com/wbkd/react-flow/discussions/2698
const hiddenNodeConnector = '!h-px !w-px !min-w-0 !min-h-0 !cursor-grab !border-0 !opacity-0'
const columnEditionContext = useColumnEditionContext()
const schemaGraphContext = useSchemaGraphContext()
const { data: project } = useSelectedProjectQuery()
const { can: canUpdateColumns } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'columns'
)
const router = useRouter()
const itemHeight = 'h-[22px]'
return (
@@ -71,38 +92,76 @@ export const TableNode = ({
>
<header
className={cn(
'text-[0.55rem] pl-2 pr-1 bg-alternative flex items-center justify-between',
'text-[0.55rem] pl-2 pr-1 bg-alternative flex gap-2 items-center justify-between',
itemHeight
)}
>
<div className="flex gap-x-1 items-center">
<div className="min-w-0 flex flex-shrink gap-x-1 items-center">
<Table2 strokeWidth={1} size={12} className="text-light" />
{data.name}
<span className="whitespace-nowrap overflow-hidden text-ellipsis" title={data.name}>
{data.name}
</span>
</div>
<div className="flex items-center gap-2">
{data.description && (
<Tooltip>
<TooltipTrigger asChild className="cursor-default ">
<InfoIcon size={10} className="text-light" />
</TooltipTrigger>
<TooltipContent side="top">{data.description}</TooltipContent>
</Tooltip>
)}
{
// Hide the actions while downloading the schema as png/svg
!schemaGraphContext.isDownloading ? (
<div className="flex flex-shrink-0 items-center gap-2">
{data.description && (
<Tooltip>
<TooltipTrigger asChild className="cursor-default ">
<InfoIcon size={10} className="text-light" />
</TooltipTrigger>
<TooltipContent side="top">{data.description}</TooltipContent>
</Tooltip>
)}
{!placeholder && (
<Button asChild type="text" className="px-0 w-[16px] h-[16px] rounded">
<Link
href={buildTableEditorUrl({
projectRef: data.ref,
tableId: data.id,
schema: data.schema,
})}
>
<ExternalLink size={10} className="text-foreground-light" />
</Link>
</Button>
)}
</div>
{!placeholder && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="text" className="px-0 w-[16px] h-[16px] rounded nodrag nopan">
<MoreVertical size={10} />
<span className="sr-only">{data.name} actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={() => schemaGraphContext.onEditTable(data.id)}
>
<Edit size={12} />
<p>Edit table</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
copyToClipboard(data.name)
}}
>
<Copy size={12} />
<span>Copy name</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={() =>
router.push(
buildTableEditorUrl({
projectRef: project?.ref,
tableId: data.id,
schema: data.schema,
})
)
}
>
<TableEditor size={12} />
<p>View in Table Editor</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
) : null
}
</header>
{data.columns.map((column) => (
@@ -117,6 +176,7 @@ export const TableNode = ({
'pr-1',
itemHeight
)}
data-testid={`${data.name}/${column.name}`}
key={column.id}
>
<div
@@ -154,11 +214,14 @@ export const TableNode = ({
<Hash size={8} strokeWidth={1} className="flex-shrink-0 text-light" />
)}
</div>
<div className="flex w-full justify-between">
<span className="text-ellipsis overflow-hidden whitespace-nowrap max-w-[85px]">
<div className="flex w-full justify-between min-w-0">
<span
className="text-ellipsis overflow-hidden whitespace-nowrap min-w-0 max-w-[80%]"
title={column.name}
>
{column.name}
</span>
<span className="pl-2 pr-1 inline-flex justify-end font-mono text-lighter text-[0.4rem] group-hover:hidden">
<span className="flex-shrink-0 pl-2 pr-1 inline-flex justify-end font-mono text-lighter text-[0.4rem] group-hover:hidden">
{column.format}
</span>
</div>
@@ -178,23 +241,50 @@ export const TableNode = ({
className={cn(hiddenNodeConnector, '!right-0')}
/>
)}
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="text"
className="hidden group-hover:inline-block absolute right-0 px-0 mr-1 w-[16px] h-[16px] rounded"
onClick={() => {
columnEditionContext.onEditColumn(data.id, column.id)
}}
// Use opacity to hide the button so that it remains accessible (users can tab to it)
className="opacity-0 focus:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100 absolute right-0 top-1/2 -translate-y-1/2 px-0 mr-1 w-[16px] h-[16px] rounded"
>
<Edit size={10} className="text-foreground-light" />
<MoreVertical size={10} />
<span className="sr-only">
Edit {data.name} {column.name} column
{data.name} {column.name} actions
</span>
</Button>
</TooltipTrigger>
<TooltipContent>Edit column</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-32">
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuItem
disabled={!canUpdateColumns}
onClick={() => schemaGraphContext.onEditColumn(data.id, column.id)}
className="space-x-2"
>
<Edit size={12} />
<p>Edit column</p>
</DropdownMenuItem>
</TooltipTrigger>
{!canUpdateColumns && (
<TooltipContent side="bottom">
Additional permissions required to edit column
</TooltipContent>
)}
</Tooltip>
<DropdownMenuItem
className="space-x-2"
onClick={(e) => {
e.stopPropagation()
copyToClipboard(column.name)
}}
>
<Copy size={12} />
<span>Copy name</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>

View File

@@ -1,5 +1,4 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import type { GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { CONSTRAINT_TYPE, useTableConstraintsQuery } from 'data/database/constraints-query'
@@ -329,11 +328,6 @@ export const TableEditor = ({
if (!tableFields) return null
const isExposed = isApiGrantTogglesEnabled
? !!apiAccessToggleHandler.data?.schemaExposed &&
checkDataApiPrivilegesNonEmpty(apiAccessToggleHandler.data.privileges)
: undefined
return (
<SidePanel
data-testid="table-editor-side-panel"
@@ -358,6 +352,7 @@ export const TableEditor = ({
<Input
data-testid="table-name-input"
label="Name"
id="name"
layout="horizontal"
type="text"
error={errors.name ? String(errors.name) : undefined}
@@ -366,6 +361,7 @@ export const TableEditor = ({
/>
<Input
label="Description"
id="description"
placeholder="Optional"
layout="horizontal"
type="text"

View File

@@ -61,12 +61,67 @@ test.describe('Database', () => {
await expect(page.getByText('users', { exact: true })).toBeVisible()
await expect(page.getByText('sso_providers', { exact: true })).toBeVisible()
await expect(page.getByText('saml_providers', { exact: true })).toBeVisible()
})
// navigate to table editor when icon is clicked
const samlProvidersHeader = await page.getByText('saml_providers', { exact: true })
await samlProvidersHeader.locator('..').getByRole('link').click()
test('table actions work as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_schema_table_actions'
const databaseColumnName = 'pw_database_schema_column_table_actions'
await using _ = await withSetupCleanup(
async () => {
await createTable(databaseTableName, databaseColumnName)
},
async () => {
await dropTable(databaseTableName)
}
)
const wait = createApiResponseWaiter(
page,
'pg-meta',
ref,
'tables?include_columns=true&included_schemas=public'
)
await page.goto(toUrl(`/project/${env.PROJECT_REF}/database/schemas?schema=public`))
await wait
// validates table and column exists
await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
// test we can edit the column
await page.getByText(`${databaseTableName} actions`).click()
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit table' }).click({ force: true })
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('timestamptz')).toBeVisible()
await page.getByLabel('Description').fill('Bazinga')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText(`Successfully updated ${databaseTableName}!`)).toBeVisible()
await expect(page.getByRole('dialog')).not.toBeVisible()
// test the schema view has been refreshed
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Edit table' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit table' }).click()
await expect(page.getByRole('dialog')).toBeVisible()
// FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled
await page.waitForTimeout(500)
await expect(page.getByLabel('Description')).toHaveValue('Bazinga')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Copy name' }).click()
await page.waitForTimeout(500)
const copiedTableResult = await page.evaluateHandle(() => navigator.clipboard.readText())
expect(await copiedTableResult.jsonValue()).toBe(databaseTableName)
await page.getByText(`${databaseTableName} actions`).click()
await expect(page.getByRole('menuitem', { name: 'View in Table Editor' })).toBeVisible()
await page.getByRole('menuitem', { name: 'View in Table Editor' }).click()
await page.waitForURL(/.*\/editor\/\d+/)
await page.getByRole('button', { name: 'View saml_providers', exact: true }).click()
await expect(page.getByRole('tab', { name: databaseTableName })).toBeVisible()
})
test('columns actions work as expected', async ({ page, ref }) => {
@@ -93,8 +148,11 @@ test.describe('Database', () => {
await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible()
// test we can edit the column
await page.getByText(databaseColumnName, { exact: true }).hover()
await page.getByText(`Edit ${databaseTableName} ${databaseColumnName} column`).click()
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit column' }).click()
await page.getByLabel('Description').fill('Bazinga')
await page.getByRole('button', { name: 'Save' }).click()
await expect(
@@ -103,9 +161,23 @@ test.describe('Database', () => {
await expect(page.getByRole('dialog')).not.toBeVisible()
// test the schema view has been refreshed
await page.getByText(databaseColumnName, { exact: true }).hover()
await page.getByText(`Edit ${databaseTableName} ${databaseColumnName} column`).click()
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Edit column' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Edit column' }).click()
await expect(page.getByLabel('Description')).toHaveValue('Bazinga')
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('dialog')).not.toBeVisible()
await page
.getByText(`${databaseTableName} ${databaseColumnName} actions`)
.click({ force: true })
await expect(page.getByRole('menuitem', { name: 'Copy name' })).toBeVisible()
await page.getByRole('menuitem', { name: 'Copy name' }).click()
await page.waitForTimeout(500)
const copiedTableResult = await page.evaluateHandle(() => navigator.clipboard.readText())
expect(await copiedTableResult.jsonValue()).toBe(databaseColumnName)
})
})
@@ -186,7 +258,7 @@ test.describe('Database', () => {
// create a new table
await page.getByRole('button', { name: 'New table' }).click()
await page.getByTestId('table-name-input').fill(databaseTableNameNew)
await page.getByLabel('Name').fill(databaseTableNameNew)
const createTableWait = createApiResponseWaiter(
page,
'pg-meta',
@@ -224,8 +296,8 @@ test.describe('Database', () => {
.last()
.click()
await page.getByRole('menuitem', { name: 'Duplicate Table' }).click()
await page.getByTestId('table-name-input').fill(databaseTableNameDuplicate)
await page.getByRole('textbox', { name: 'Optional' }).fill('')
await page.getByLabel('Name').fill(databaseTableNameDuplicate)
await page.getByLabel('Description').fill('')
const duplicateTableWait = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=')
await page.getByRole('button', { name: 'Save' }).click()