feat: Allow to edit a column from the schema visualiser (#43592)

## Problem

Editing a column from the schema visualiser requires many clicks

## Solution

When hovering over a column in the schema visualiser, an edit button
should appear on the right side. Clicking this button should open the
column edit pane on the right side of the screen. This would reduce the
number of clicks required and allow users to make edits directly from
the visualiser instead of using it only as a visual aid.
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-03-10 19:28:38 +01:00
1 parent dcbaaad64e
commit f66eb3f7bd
7 files changed
+186 -58

No files matched your search

@@ -0,0 +1,22 @@
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
}
@@ -1,10 +1,10 @@
import type { PostgresSchema } from '@supabase/postgres-meta'
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { toPng, toSvg } from 'html-to-image'
import { Check, Copy, Download, Loader2, Plus } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow'
import 'reactflow/dist/style.css'
@@ -22,6 +22,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsProtectedSchema } from 'hooks/useProtectedSchemas'
import { tablesToSQL } from 'lib/helpers'
import { toast } from 'sonner'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import {
Button,
copyToClipboard,
@@ -32,6 +33,8 @@ import {
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor'
import { ColumnEditionContextProvider, ColumnEditionContextType } from './ColumnEditionContext'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
@@ -43,6 +46,8 @@ export const SchemaGraph = () => {
const { resolvedTheme } = useTheme()
const { data: project } = useSelectedProjectQuery()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const [selectedTable, setSelectedTable] = useState<PostgresTable | null>(null)
const snap = useTableEditorStateSnapshot()
const [copied, setCopied] = useState(false)
useEffect(() => {
@@ -191,17 +196,38 @@ export const SchemaGraph = () => {
}
}
const isFirstLoad = useRef(true)
useEffect(() => {
if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema
getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => {
reactFlowInstance.setNodes(nodes)
reactFlowInstance.setEdges(edges)
setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
// Prevent resetting a view after first load to avoid layout changes after editing a column
if (isFirstLoad.current) {
isFirstLoad.current = false
setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
}
})
}
}, [isSuccessTables, isSuccessSchemas, tables, resolvedTheme])
const columnEditionContext = useMemo<ColumnEditionContextType>(
() => ({
onEditColumn: (tableId, columnId) => {
const table = tables.find((table) => table.id === tableId)
if (!table || table.columns == null) return
const column = table.columns.find((column) => column.id === columnId)
if (!column) return
setSelectedTable(table)
snap.onEditColumn(column)
},
}),
[tables, snap]
)
return (
<>
<div className="flex items-center justify-between p-4 border-b border-muted h-[var(--header-height)]">
@@ -323,41 +349,44 @@ export const SchemaGraph = () => {
</Admonition>
</div>
) : (
<div className="w-full h-full">
<ReactFlow
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
deletable: false,
}}
nodeTypes={nodeTypes}
fitView
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onNodeDragStop={() => saveNodePositions()}
>
<Background
gap={16}
className="[&>*]:stroke-foreground-muted opacity-[25%]"
variant={BackgroundVariant.Dots}
color={'inherit'}
/>
<MiniMap
pannable
zoomable
nodeColor={miniMapNodeColor}
maskColor={miniMapMaskColor}
className="border rounded-md shadow-sm"
/>
<SchemaGraphLegend />
</ReactFlow>
</div>
<ColumnEditionContextProvider value={columnEditionContext}>
<div className="w-full h-full">
<ReactFlow
defaultNodes={[]}
defaultEdges={[]}
defaultEdgeOptions={{
type: 'smoothstep',
animated: true,
deletable: false,
}}
nodeTypes={nodeTypes}
fitView
minZoom={0.8}
maxZoom={1.8}
proOptions={{ hideAttribution: true }}
onNodeDragStop={() => saveNodePositions()}
>
<Background
gap={16}
className="[&>*]:stroke-foreground-muted opacity-[25%]"
variant={BackgroundVariant.Dots}
color={'inherit'}
/>
<MiniMap
pannable
zoomable
nodeColor={miniMapNodeColor}
maskColor={miniMapMaskColor}
className="border rounded-md shadow-sm"
/>
<SchemaGraphLegend />
</ReactFlow>
</div>
</ColumnEditionContextProvider>
)}
</>
)}
<SidePanelEditor selectedTable={selectedTable ?? undefined} includeColumns />
</>
)
}
@@ -1,9 +1,20 @@
import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils'
import { DiamondIcon, ExternalLink, Fingerprint, Hash, InfoIcon, Key, Table2 } from 'lucide-react'
import {
DiamondIcon,
Edit,
ExternalLink,
Fingerprint,
Hash,
InfoIcon,
Key,
Table2,
} from 'lucide-react'
import Link from 'next/link'
import { Handle, NodeProps } from 'reactflow'
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useColumnEditionContext } from './ColumnEditionContext'
// ReactFlow is scaling everything by the factor of 2
export const TABLE_NODE_WIDTH = 320
export const TABLE_NODE_ROW_HEIGHT = 40
@@ -35,6 +46,7 @@ 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 itemHeight = 'h-[22px]'
@@ -101,6 +113,8 @@ export const TableNode = ({
'border-t',
'border-t-[0.5px]',
'hover:bg-scale-500 transition cursor-default',
'group',
'pr-1',
itemHeight
)}
key={column.id}
@@ -144,7 +158,7 @@ export const TableNode = ({
<span className="text-ellipsis overflow-hidden whitespace-nowrap max-w-[85px]">
{column.name}
</span>
<span className="px-2 inline-flex justify-end font-mono text-lighter text-[0.4rem]">
<span className="pl-2 pr-1 inline-flex justify-end font-mono text-lighter text-[0.4rem] group-hover:hidden">
{column.format}
</span>
</div>
@@ -164,6 +178,23 @@ export const TableNode = ({
className={cn(hiddenNodeConnector, '!right-0')}
/>
)}
<Tooltip>
<TooltipTrigger 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)
}}
>
<Edit size={10} className="text-foreground-light" />
<span className="sr-only">
Edit {data.name} {column.name} column
</span>
</Button>
</TooltipTrigger>
<TooltipContent>Edit column</TooltipContent>
</Tooltip>
</div>
))}
</div>
@@ -243,6 +243,7 @@ export const ColumnEditor = ({
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
label="Name"
type="text"
descriptionText="Recommended to use lowercase and use an underscore to separate words e.g. column_name"
@@ -252,6 +253,7 @@ export const ColumnEditor = ({
onChange={(event: any) => onUpdateField({ name: event.target.value })}
/>
<Input
id="description"
label="Description"
labelOptional="Optional"
type="text"
@@ -495,6 +495,9 @@ export const SidePanelEditor = ({
queryKey: databaseKeys.tableDefinition(project?.ref, selectedTable?.id),
}),
queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(project?.ref) }),
queryClient.invalidateQueries({
queryKey: tableKeys.list(project?.ref, selectedTable?.schema, includeColumns),
}),
])
// We need to invalidate tableRowsAndCount after tableEditor
+1 -1
View File
@@ -64,7 +64,7 @@ export async function getTables(
return sortBy(data, (t) => t[sortByProperty]) as PostgresTable[]
}
return data as Omit<PostgresTable, 'columns'>[]
return data as PostgresTable[]
}
export type TablesData = Awaited<ReturnType<typeof getTables>>
+61 -20
View File
@@ -10,8 +10,6 @@ import {
waitForDatabaseToLoad,
} from '../utils/wait-for-response.js'
const databaseColumnName = 'pw_database_column'
test.describe('Database', () => {
test.describe('Schema Visualizer', () => {
test('actions works as expected', async ({ page, ref }) => {
@@ -36,7 +34,7 @@ test.describe('Database', () => {
// validates table and column exists
await expect(page.getByText(databaseTableName, { exact: true })).toBeVisible()
await expect(page.getByText(databaseColumnName)).toBeVisible()
await expect(page.getByText(databaseColumnName, { exact: true })).toBeVisible()
// copies schema definition to clipboard
await page.getByRole('button', { name: 'Copy as SQL' }).click()
@@ -60,21 +58,61 @@ test.describe('Database', () => {
await page.getByTestId('schema-selector').click()
await page.getByRole('option', { name: 'auth' }).click()
await waitForDatabaseToLoad(page, ref, 'auth')
await expect(page.getByText('users')).toBeVisible()
await expect(page.getByText('sso_providers')).toBeVisible()
await expect(page.getByText('saml_providers')).toBeVisible()
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')
const samlProvidersHeader = await page.getByText('saml_providers', { exact: true })
await samlProvidersHeader.locator('..').getByRole('link').click()
await page.waitForURL(/.*\/editor\/\d+/)
await page.getByRole('button', { name: 'View saml_providers', exact: true }).click()
})
test('columns actions work as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_schema_columns_actions'
const databaseColumnName = 'pw_database_schema_column_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()
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.getByLabel('Description').fill('Bazinga')
await page.getByRole('button', { name: 'Save' }).click()
await expect(
page.getByText(`Successfully updated column "${databaseColumnName}"`)
).toBeVisible()
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 expect(page.getByLabel('Description')).toHaveValue('Bazinga')
})
})
test.describe('Tables', () => {
test('actions works as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_actions_table'
const databaseTableName = 'pw_database_table_actions'
const databaseColumnName = 'pw_database_column_actions'
await using _ = await withSetupCleanup(
async () => {
await createTable(databaseTableName, databaseColumnName)
@@ -124,10 +162,11 @@ test.describe('Database', () => {
})
test('CRUD operations and copy works as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_tablecrud_table'
const databaseTableNameNew = 'pw_database_table_new'
const databaseTableNameUpdated = 'pw_database_table_updated'
const databaseTableNameDuplicate = 'pw_database_table_duplicate'
const databaseTableName = 'pw_database_table_crud_table'
const databaseTableNameNew = 'pw_database_table_crud_new'
const databaseTableNameUpdated = 'pw_database_table_crud_updated'
const databaseTableNameDuplicate = 'pw_database_table_crud_duplicate'
const databaseColumnName = 'pw_database_column_table_crud'
await using _ = await withSetupCleanup(
async () => {
@@ -238,8 +277,9 @@ test.describe('Database', () => {
test.describe('Tables columns', () => {
test('can view, create, update, delete, and filter table columns', async ({ page, ref }) => {
const databaseTableName = 'pw_database_columns_table'
const databaseColumnName2 = 'pw_database_column_2'
const databaseColumnName3 = 'pw_database_column_3'
const databaseColumnName = 'pw_database_column_crud'
const databaseColumnName2 = 'pw_database_column_crud_2'
const databaseColumnName3 = 'pw_database_column_crud_3'
await using _ = await withSetupCleanup(
async () => {
@@ -267,9 +307,8 @@ test.describe('Database', () => {
// create a new table column
await page.getByRole('button', { name: 'New column' }).click()
await page
.getByRole('textbox', { name: 'column_name', exact: true })
.fill('pw_database_column_2')
await expect(page.getByRole('dialog')).toBeVisible()
await page.getByLabel('name').fill(databaseColumnName2)
await page.getByText('Choose a column type...').click()
await page.getByText('numeric', { exact: true }).click()
const columnCreateWait = createApiResponseWaiter(
@@ -296,7 +335,7 @@ test.describe('Database', () => {
// update table column
await columnDatabase2Row.getByRole('button').click()
await page.getByRole('button', { name: 'Edit column' }).click()
await page.getByRole('textbox', { name: 'column_name' }).fill(databaseColumnName3)
await page.getByLabel('name').fill(databaseColumnName3)
const columnUpdateWait = createApiResponseWaiter(
page,
'pg-meta',
@@ -375,6 +414,7 @@ test.describe('Database', () => {
test('CRUD operations works as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_trigger_table'
const databaseColumnName = 'pw_database_column_trigger'
const databaseTriggerName = 'pw_database_trigger'
const databaseTriggerNameUpdated = 'pw_database_trigger_updated'
@@ -513,6 +553,7 @@ test.describe('Database', () => {
test('CRUD operations works as expected', async ({ page, ref }) => {
const databaseTableName = 'pw_database_indexes_table'
const databaseColumnName = 'pw_database_column_index'
const databaseIndexName = 'pw_database_index'
await using _ = await withSetupCleanup(
@@ -754,8 +795,8 @@ test.describe('Database Functions', () => {
// Nothing
},
async () => {
await query(`drop function if exists ${databaseFunctionName}`);
await query(`drop function if exists ${databaseFunctionNameUpdated}`);
await query(`drop function if exists ${databaseFunctionName}`)
await query(`drop function if exists ${databaseFunctionNameUpdated}`)
}
)