From f66eb3f7bd810a36fc95ed040f88597a1290ad55 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:28:38 +0100 Subject: [PATCH] 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. --- .../Database/Schemas/ColumnEditionContext.tsx | 22 +++++ .../Database/Schemas/SchemaGraph.tsx | 99 ++++++++++++------- .../Database/Schemas/SchemaTableNode.tsx | 35 ++++++- .../ColumnEditor/ColumnEditor.tsx | 2 + .../SidePanelEditor/SidePanelEditor.tsx | 3 + apps/studio/data/tables/tables-query.ts | 2 +- e2e/studio/features/database.spec.ts | 81 +++++++++++---- 7 files changed, 186 insertions(+), 58 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx diff --git a/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx b/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx new file mode 100644 index 00000000000..447e018bfcd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext, type ReactNode } from 'react' + +export type ColumnEditionContextType = { + onEditColumn: (tableId: number, columnId: string) => void +} + +export const ColumnEditionContext = createContext(null) + +export const ColumnEditionContextProvider = ({ + children, + value, +}: { + children: ReactNode + value: ColumnEditionContextType +}) => {children} + +export const useColumnEditionContext = () => { + const context = useContext(ColumnEditionContext) + if (!context) + throw new Error('useColumnEditionContext must be used inside a ') + return context +} diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 58186d3c164..2f3d3ae9113 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -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(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( + () => ({ + 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 ( <>
@@ -323,41 +349,44 @@ export const SchemaGraph = () => {
) : ( -
- saveNodePositions()} - > - - - - -
+ +
+ saveNodePositions()} + > + + + + +
+
)} )} + ) } diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index 4881bfdca67..769117f37f3 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -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 = ({ {column.name} - + {column.format} @@ -164,6 +178,23 @@ export const TableNode = ({ className={cn(hiddenNodeConnector, '!right-0')} /> )} + + + + + Edit column + ))} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index d0f96cdad89..a6168620d14 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -243,6 +243,7 @@ export const ColumnEditor = ({ General}> onUpdateField({ name: event.target.value })} /> t[sortByProperty]) as PostgresTable[] } - return data as Omit[] + return data as PostgresTable[] } export type TablesData = Awaited> diff --git a/e2e/studio/features/database.spec.ts b/e2e/studio/features/database.spec.ts index 3058602b6c4..fd257460c5d 100644 --- a/e2e/studio/features/database.spec.ts +++ b/e2e/studio/features/database.spec.ts @@ -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}`) } )