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}`) } )