diff --git a/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx b/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx deleted file mode 100644 index 447e018bfcd..00000000000 --- a/apps/studio/components/interfaces/Database/Schemas/ColumnEditionContext.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 2f3d3ae9113..16bb938e281 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -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( + const schemaGraphPanelEditorContext = useMemo( () => ({ + 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 = () => { ) : ( - +
{
-
+ )} )} diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraphContext.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraphContext.tsx new file mode 100644 index 00000000000..514168908b8 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraphContext.tsx @@ -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(null) + +export const SchemaGraphContextProvider = ({ + children, + value, +}: { + children: ReactNode + value: SchemaGraphContextType +}) => {children} + +export const useSchemaGraphContext = () => { + const context = useContext(SchemaGraphContext) + if (!context) + throw new Error('useSchemaGraphContext must be used inside a ') + return context +} diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index 769117f37f3..a771bb5829e 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -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 = ({ >
-
+
- {data.name} + + {data.name} +
-
- {data.description && ( - - - - - {data.description} - - )} + { + // Hide the actions while downloading the schema as png/svg + !schemaGraphContext.isDownloading ? ( +
+ {data.description && ( + + + + + {data.description} + + )} - {!placeholder && ( - - )} -
+ {!placeholder && ( + + + + + + schemaGraphContext.onEditTable(data.id)} + > + +

Edit table

+
+ { + e.stopPropagation() + copyToClipboard(data.name) + }} + > + + Copy name + + + router.push( + buildTableEditorUrl({ + projectRef: project?.ref, + tableId: data.id, + schema: data.schema, + }) + ) + } + > + +

View in Table Editor

+
+
+
+ )} +
+ ) : null + }
{data.columns.map((column) => ( @@ -117,6 +176,7 @@ export const TableNode = ({ 'pr-1', itemHeight )} + data-testid={`${data.name}/${column.name}`} key={column.id} >
)}
-
- +
+ {column.name} - + {column.format}
@@ -178,23 +241,50 @@ export const TableNode = ({ className={cn(hiddenNodeConnector, '!right-0')} /> )} - - + + - - Edit column - + + + + + schemaGraphContext.onEditColumn(data.id, column.id)} + className="space-x-2" + > + +

Edit column

+
+
+ {!canUpdateColumns && ( + + Additional permissions required to edit column + + )} +
+ + { + e.stopPropagation() + copyToClipboard(column.name) + }} + > + + Copy name + +
+
))} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 9ff0aa34781..8758cb878b3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -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 ( { 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()