import type { PGSchema } from '@supabase/pg-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Background, BackgroundVariant, ColorMode, Edge, MiniMap, Node, OnSelectionChangeParams, ReactFlow, useReactFlow, } from '@xyflow/react' import { Check, ChevronDown, Copy, Download, Loader2, Plus } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import '@xyflow/react/dist/style.css' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, Button, copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor' import { DefaultEdge } from './DefaultEdge' import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext' import { SchemaGraphLegend } from './SchemaGraphLegend' import { EdgeData, TableNodeData } from './Schemas.constants' import { getGraphDataFromTables, getLayoutedElementsViaDagre, getSchemaAsMarkdown, } from './Schemas.utils' import { TableNode } from './SchemaTableNode' import { useExportSchemaToImage } from './useExportSchemaToImage' import AlertError from '@/components/ui/AlertError' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import SchemaSelector from '@/components/ui/SchemaSelector' import { Shortcut } from '@/components/ui/Shortcut' import { useSchemasQuery } from '@/data/database/schemas-query' import { useTablesQuery } from '@/data/tables/tables-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useLocalStorage } from '@/hooks/misc/useLocalStorage' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas' import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent' import { tablesToSQL } from '@/lib/helpers' import type { SafePostgresTable } from '@/lib/postgres-types' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' import { useTableEditorStateSnapshot } from '@/state/table-editor' // [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout export const SchemaGraph = () => { const { ref } = useParams() const { resolvedTheme } = useTheme() const { data: project } = useSelectedProjectQuery() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const [selectedTable, setSelectedTable] = useState(null) const snap = useTableEditorStateSnapshot() const { isDownloading, exportSchemaToImage } = useExportSchemaToImage() const [copied, setCopied] = useState(false) useEffect(() => { if (copied) { setTimeout(() => setCopied(false), 2000) } }, [copied]) const miniMapNodeColor = '#111318' const miniMapMaskColor = resolvedTheme?.includes('dark') ? 'rgb(17, 19, 24, .8)' : 'rgb(237, 237, 237, .8)' const reactFlowInstance = useReactFlow() const nodeTypes = useMemo( () => ({ table: TableNode, }), [] ) const edgeTypes = useMemo( () => ({ default: DefaultEdge, }), [] ) const { data: schemas, error: errorSchemas, isSuccess: isSuccessSchemas, isPending: isLoadingSchemas, isError: isErrorSchemas, } = useSchemasQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) const { data: tables = [], error: errorTables, isSuccess: isSuccessTables, isPending: isLoadingTables, isError: isErrorTables, } = useTablesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, schema: selectedSchema, includeColumns: true, }) const hasNoTables = isSuccessSchemas && tables.length === 0 const schema = (schemas ?? []).find((s) => s.name === selectedSchema) const [, setStoredPositions] = useLocalStorage( LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref as string, schema?.id ?? 0), {} ) const { can: canUpdateTables } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables' ) const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const canAddTables = canUpdateTables && !isSchemaLocked const resetLayout = async () => { const nodes = reactFlowInstance.getNodes() const edges = reactFlowInstance.getEdges() getLayoutedElementsViaDagre( nodes.filter((item) => item.type === 'table') as Node[], edges ) reactFlowInstance.setNodes(nodes) reactFlowInstance.setEdges(edges) await new Promise((resolve) => setTimeout(async () => { await reactFlowInstance.fitView({}) resolve() }) ) saveNodePositions() } const saveNodePositions = useStaticEffectEvent(() => { if (schema === undefined) return console.error('Schema is required') const nodes = reactFlowInstance.getNodes() if (nodes.length > 0) { const nodesPositionData = nodes.reduce((a, b) => { return { ...a, [b.id]: b.position } }, {}) setStoredPositions(nodesPositionData) } }) const [selectedEdge, setSelectedEdge] = useState(undefined) const handleSelectionChange = useStaticEffectEvent( (params: OnSelectionChangeParams, Edge>) => { if (params.edges.length === 1) { setSelectedEdge(params.edges[0]) } else { setSelectedEdge(undefined) } const selectedNodeIds = new Set(params.nodes.map((n) => n.id)) reactFlowInstance.setEdges( reactFlowInstance.getEdges().map((edge) => ({ ...edge, animated: selectedNodeIds.size > 0 && (selectedNodeIds.has(edge.source) || selectedNodeIds.has(edge.target)), })) ) } ) const downloadImage = async (format: 'png' | 'svg') => { const reactflowViewport = document.querySelector('.react-flow__viewport') as HTMLElement if (!reactflowViewport) return if (!ref) return const { x, y, zoom } = reactFlowInstance.getViewport() exportSchemaToImage({ element: reactflowViewport, format, x, y, zoom, projectRef: ref }) } const copyAsSQL = () => { if (!tables) return copyToClipboard(tablesToSQL(tables)) setCopied(true) toast.success('Successfully copied as SQL') } const copyAsMarkdown = () => { const tableNodes = reactFlowInstance .getNodes() .filter((node) => node.type === 'table') .map((node) => node.data as TableNodeData) copyToClipboard(getSchemaAsMarkdown(selectedSchema, tableNodes)) setCopied(true) toast.success('Successfully copied as Markdown') } const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false) const [autoLayoutDialogOpen, setAutoLayoutDialogOpen] = useState(false) const shortcutsEnabled = isSuccessSchemas && !hasNoTables useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_SQL, copyAsSQL, { enabled: shortcutsEnabled }) useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_MARKDOWN, copyAsMarkdown, { enabled: shortcutsEnabled, }) useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_PNG, () => downloadImage('png'), { enabled: shortcutsEnabled, }) useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_SVG, () => downloadImage('svg'), { enabled: shortcutsEnabled, }) const isFirstLoad = useRef(true) useEffect(() => { if (isSuccessTables && isSuccessSchemas && tables.length > 0) { const schema = schemas.find((s) => s.name === selectedSchema) as PGSchema getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => { reactFlowInstance.setNodes(nodes) reactFlowInstance.setEdges(edges) // 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, reactFlowInstance, ref, resolvedTheme, schemas, selectedSchema, ]) const schemaGraphContext = useMemo( () => ({ selectedEdge, isDownloading, 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) }, onEditTable: (tableId) => { const table = tables.find((table) => table.id === tableId) if (!table || table.columns == null) return setSelectedTable(table) snap.onEditTable() }, }), [tables, snap, isDownloading, selectedEdge] ) return ( <>
{isLoadingSchemas && (
)} {isErrorSchemas && } {isSuccessSchemas && ( <> setSchemaSelectorOpen(true)} options={{ enabled: isSuccessSchemas }} side="bottom" tooltipOpen={schemaSelectorOpen ? false : undefined} > {!hasNoTables && (
: } onClick={copyAsSQL} tooltip={{ content: { side: 'bottom', text: (

Note

This schema is for context or debugging only. Table order and constraints may be invalid. Not meant to be run as-is.

), }, }} > Copy as SQL
{ e.stopPropagation() copyAsMarkdown() }} > Copy as Markdown { e.stopPropagation() downloadImage('png') }} > Download as PNG { e.stopPropagation() downloadImage('svg') }} > Download as SVG
setAutoLayoutDialogOpen(true)} options={{ enabled: shortcutsEnabled }} side="bottom" tooltipOpen={autoLayoutDialogOpen ? false : undefined} > Confirm to rearrange all nodes Auto layout will rearrange all nodes in the graph. This cannot be undone. Cancel Apply
)} )}
{isLoadingTables && (

Loading tables

)} {isErrorTables && (
)} {isSuccessTables && ( <> {hasNoTables ? (
{canAddTables && ( )}
) : (
, Edge> // FIXME: https://github.com/xyflow/xyflow/issues/4876 colorMode={'' as unknown as ColorMode} defaultNodes={[]} defaultEdges={[]} defaultEdgeOptions={{ type: 'default', animated: false, deletable: false, }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} fitView minZoom={0.8} maxZoom={1.8} proOptions={{ hideAttribution: true }} onNodeDragStop={saveNodePositions} onSelectionChange={handleSelectionChange} >
)} )} ) }