mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user