SchemaGraph.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import type { PGSchema } from '@supabase/pg-meta'
  2. import { PermissionAction } from '@supabase/shared-types/out/constants'
  3. import {
  4. Background,
  5. BackgroundVariant,
  6. ColorMode,
  7. Edge,
  8. MiniMap,
  9. Node,
  10. OnSelectionChangeParams,
  11. ReactFlow,
  12. useReactFlow,
  13. } from '@xyflow/react'
  14. import { Check, ChevronDown, Copy, Download, Loader2, Plus } from 'lucide-react'
  15. import { useTheme } from 'next-themes'
  16. import Link from 'next/link'
  17. import { useEffect, useMemo, useRef, useState } from 'react'
  18. import { toast } from 'sonner'
  19. import '@xyflow/react/dist/style.css'
  20. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  21. import {
  22. AlertDialog,
  23. AlertDialogAction,
  24. AlertDialogCancel,
  25. AlertDialogContent,
  26. AlertDialogDescription,
  27. AlertDialogFooter,
  28. AlertDialogHeader,
  29. AlertDialogTitle,
  30. AlertDialogTrigger,
  31. Button,
  32. copyToClipboard,
  33. DropdownMenu,
  34. DropdownMenuContent,
  35. DropdownMenuItem,
  36. DropdownMenuTrigger,
  37. } from 'ui'
  38. import { Admonition } from 'ui-patterns/admonition'
  39. import { SidePanelEditor } from '../../TableGridEditor/SidePanelEditor/SidePanelEditor'
  40. import { DefaultEdge } from './DefaultEdge'
  41. import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext'
  42. import { SchemaGraphLegend } from './SchemaGraphLegend'
  43. import { EdgeData, TableNodeData } from './Schemas.constants'
  44. import {
  45. getGraphDataFromTables,
  46. getLayoutedElementsViaDagre,
  47. getSchemaAsMarkdown,
  48. } from './Schemas.utils'
  49. import { TableNode } from './SchemaTableNode'
  50. import { useExportSchemaToImage } from './useExportSchemaToImage'
  51. import AlertError from '@/components/ui/AlertError'
  52. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  53. import SchemaSelector from '@/components/ui/SchemaSelector'
  54. import { Shortcut } from '@/components/ui/Shortcut'
  55. import { useSchemasQuery } from '@/data/database/schemas-query'
  56. import { useTablesQuery } from '@/data/tables/tables-query'
  57. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  58. import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
  59. import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
  60. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  61. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  62. import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
  63. import { tablesToSQL } from '@/lib/helpers'
  64. import type { SafePostgresTable } from '@/lib/postgres-types'
  65. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  66. import { useShortcut } from '@/state/shortcuts/useShortcut'
  67. import { useTableEditorStateSnapshot } from '@/state/table-editor'
  68. // [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout
  69. export const SchemaGraph = () => {
  70. const { ref } = useParams()
  71. const { resolvedTheme } = useTheme()
  72. const { data: project } = useSelectedProjectQuery()
  73. const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
  74. const [selectedTable, setSelectedTable] = useState<SafePostgresTable | null>(null)
  75. const snap = useTableEditorStateSnapshot()
  76. const { isDownloading, exportSchemaToImage } = useExportSchemaToImage()
  77. const [copied, setCopied] = useState(false)
  78. useEffect(() => {
  79. if (copied) {
  80. setTimeout(() => setCopied(false), 2000)
  81. }
  82. }, [copied])
  83. const miniMapNodeColor = '#111318'
  84. const miniMapMaskColor = resolvedTheme?.includes('dark')
  85. ? 'rgb(17, 19, 24, .8)'
  86. : 'rgb(237, 237, 237, .8)'
  87. const reactFlowInstance = useReactFlow()
  88. const nodeTypes = useMemo(
  89. () => ({
  90. table: TableNode,
  91. }),
  92. []
  93. )
  94. const edgeTypes = useMemo(
  95. () => ({
  96. default: DefaultEdge,
  97. }),
  98. []
  99. )
  100. const {
  101. data: schemas,
  102. error: errorSchemas,
  103. isSuccess: isSuccessSchemas,
  104. isPending: isLoadingSchemas,
  105. isError: isErrorSchemas,
  106. } = useSchemasQuery({
  107. projectRef: project?.ref,
  108. connectionString: project?.connectionString,
  109. })
  110. const {
  111. data: tables = [],
  112. error: errorTables,
  113. isSuccess: isSuccessTables,
  114. isPending: isLoadingTables,
  115. isError: isErrorTables,
  116. } = useTablesQuery({
  117. projectRef: project?.ref,
  118. connectionString: project?.connectionString,
  119. schema: selectedSchema,
  120. includeColumns: true,
  121. })
  122. const hasNoTables = isSuccessSchemas && tables.length === 0
  123. const schema = (schemas ?? []).find((s) => s.name === selectedSchema)
  124. const [, setStoredPositions] = useLocalStorage(
  125. LOCAL_STORAGE_KEYS.SCHEMA_VISUALIZER_POSITIONS(ref as string, schema?.id ?? 0),
  126. {}
  127. )
  128. const { can: canUpdateTables } = useAsyncCheckPermissions(
  129. PermissionAction.TENANT_SQL_ADMIN_WRITE,
  130. 'tables'
  131. )
  132. const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema })
  133. const canAddTables = canUpdateTables && !isSchemaLocked
  134. const resetLayout = async () => {
  135. const nodes = reactFlowInstance.getNodes()
  136. const edges = reactFlowInstance.getEdges()
  137. getLayoutedElementsViaDagre(
  138. nodes.filter((item) => item.type === 'table') as Node<TableNodeData>[],
  139. edges
  140. )
  141. reactFlowInstance.setNodes(nodes)
  142. reactFlowInstance.setEdges(edges)
  143. await new Promise<void>((resolve) =>
  144. setTimeout(async () => {
  145. await reactFlowInstance.fitView({})
  146. resolve()
  147. })
  148. )
  149. saveNodePositions()
  150. }
  151. const saveNodePositions = useStaticEffectEvent(() => {
  152. if (schema === undefined) return console.error('Schema is required')
  153. const nodes = reactFlowInstance.getNodes()
  154. if (nodes.length > 0) {
  155. const nodesPositionData = nodes.reduce((a, b) => {
  156. return { ...a, [b.id]: b.position }
  157. }, {})
  158. setStoredPositions(nodesPositionData)
  159. }
  160. })
  161. const [selectedEdge, setSelectedEdge] = useState<Edge | undefined>(undefined)
  162. const handleSelectionChange = useStaticEffectEvent(
  163. (params: OnSelectionChangeParams<Node<TableNodeData>, Edge<EdgeData>>) => {
  164. if (params.edges.length === 1) {
  165. setSelectedEdge(params.edges[0])
  166. } else {
  167. setSelectedEdge(undefined)
  168. }
  169. const selectedNodeIds = new Set(params.nodes.map((n) => n.id))
  170. reactFlowInstance.setEdges(
  171. reactFlowInstance.getEdges().map((edge) => ({
  172. ...edge,
  173. animated:
  174. selectedNodeIds.size > 0 &&
  175. (selectedNodeIds.has(edge.source) || selectedNodeIds.has(edge.target)),
  176. }))
  177. )
  178. }
  179. )
  180. const downloadImage = async (format: 'png' | 'svg') => {
  181. const reactflowViewport = document.querySelector('.react-flow__viewport') as HTMLElement
  182. if (!reactflowViewport) return
  183. if (!ref) return
  184. const { x, y, zoom } = reactFlowInstance.getViewport()
  185. exportSchemaToImage({ element: reactflowViewport, format, x, y, zoom, projectRef: ref })
  186. }
  187. const copyAsSQL = () => {
  188. if (!tables) return
  189. copyToClipboard(tablesToSQL(tables))
  190. setCopied(true)
  191. toast.success('Successfully copied as SQL')
  192. }
  193. const copyAsMarkdown = () => {
  194. const tableNodes = reactFlowInstance
  195. .getNodes()
  196. .filter((node) => node.type === 'table')
  197. .map((node) => node.data as TableNodeData)
  198. copyToClipboard(getSchemaAsMarkdown(selectedSchema, tableNodes))
  199. setCopied(true)
  200. toast.success('Successfully copied as Markdown')
  201. }
  202. const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
  203. const [autoLayoutDialogOpen, setAutoLayoutDialogOpen] = useState(false)
  204. const shortcutsEnabled = isSuccessSchemas && !hasNoTables
  205. useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_SQL, copyAsSQL, { enabled: shortcutsEnabled })
  206. useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_COPY_MARKDOWN, copyAsMarkdown, {
  207. enabled: shortcutsEnabled,
  208. })
  209. useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_PNG, () => downloadImage('png'), {
  210. enabled: shortcutsEnabled,
  211. })
  212. useShortcut(SHORTCUT_IDS.SCHEMA_VISUALIZER_DOWNLOAD_SVG, () => downloadImage('svg'), {
  213. enabled: shortcutsEnabled,
  214. })
  215. const isFirstLoad = useRef(true)
  216. useEffect(() => {
  217. if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
  218. const schema = schemas.find((s) => s.name === selectedSchema) as PGSchema
  219. getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => {
  220. reactFlowInstance.setNodes(nodes)
  221. reactFlowInstance.setEdges(edges)
  222. // Prevent resetting a view after first load to avoid layout changes after editing a column
  223. if (isFirstLoad.current) {
  224. isFirstLoad.current = false
  225. setTimeout(() => reactFlowInstance.fitView({})) // it needs to happen during next event tick
  226. }
  227. })
  228. }
  229. }, [
  230. isSuccessTables,
  231. isSuccessSchemas,
  232. tables,
  233. reactFlowInstance,
  234. ref,
  235. resolvedTheme,
  236. schemas,
  237. selectedSchema,
  238. ])
  239. const schemaGraphContext = useMemo<SchemaGraphContextType>(
  240. () => ({
  241. selectedEdge,
  242. isDownloading,
  243. onEditColumn: (tableId, columnId) => {
  244. const table = tables.find((table) => table.id === tableId)
  245. if (!table || table.columns == null) return
  246. const column = table.columns.find((column) => column.id === columnId)
  247. if (!column) return
  248. setSelectedTable(table)
  249. snap.onEditColumn(column)
  250. },
  251. onEditTable: (tableId) => {
  252. const table = tables.find((table) => table.id === tableId)
  253. if (!table || table.columns == null) return
  254. setSelectedTable(table)
  255. snap.onEditTable()
  256. },
  257. }),
  258. [tables, snap, isDownloading, selectedEdge]
  259. )
  260. return (
  261. <>
  262. <div className="flex items-center justify-between p-4 border-b border-muted h-(--header-height)">
  263. {isLoadingSchemas && (
  264. <div className="h-[34px] w-[260px] bg-foreground-lighter rounded-sm shimmering-loader" />
  265. )}
  266. {isErrorSchemas && <AlertError error={errorSchemas} subject="Failed to retrieve schemas" />}
  267. {isSuccessSchemas && (
  268. <>
  269. <Shortcut
  270. id={SHORTCUT_IDS.SCHEMA_VISUALIZER_FOCUS_SCHEMA}
  271. onTrigger={() => setSchemaSelectorOpen(true)}
  272. options={{ enabled: isSuccessSchemas }}
  273. side="bottom"
  274. tooltipOpen={schemaSelectorOpen ? false : undefined}
  275. >
  276. <SchemaSelector
  277. className="w-[180px]"
  278. size="tiny"
  279. showError={false}
  280. selectedSchemaName={selectedSchema}
  281. onSelectSchema={setSelectedSchema}
  282. open={schemaSelectorOpen}
  283. onOpenChange={setSchemaSelectorOpen}
  284. />
  285. </Shortcut>
  286. {!hasNoTables && (
  287. <div className="flex items-center gap-x-2">
  288. <div className="flex items-center gap-0">
  289. <ButtonTooltip
  290. type="default"
  291. className="rounded-r-none border-r-0"
  292. icon={copied ? <Check data-testid="copy-sql-ready" /> : <Copy />}
  293. onClick={copyAsSQL}
  294. tooltip={{
  295. content: {
  296. side: 'bottom',
  297. text: (
  298. <div className="max-w-[180px] space-y-2 text-foreground-light">
  299. <p className="text-foreground">Note</p>
  300. <p>
  301. This schema is for context or debugging only. Table order and
  302. constraints may be invalid. Not meant to be run as-is.
  303. </p>
  304. </div>
  305. ),
  306. },
  307. }}
  308. >
  309. Copy as SQL
  310. </ButtonTooltip>
  311. <DropdownMenu>
  312. <DropdownMenuTrigger asChild>
  313. <Button
  314. type="default"
  315. size="tiny"
  316. className="rounded-l-none pl-1 pr-0"
  317. icon={<ChevronDown size={12} />}
  318. >
  319. <span className="sr-only">Export options</span>
  320. </Button>
  321. </DropdownMenuTrigger>
  322. <DropdownMenuContent align="end" className="w-44">
  323. <DropdownMenuItem
  324. className="flex items-center space-x-2 whitespace-nowrap"
  325. onClick={(e) => {
  326. e.stopPropagation()
  327. copyAsMarkdown()
  328. }}
  329. >
  330. <Copy size={12} />
  331. <span>Copy as Markdown</span>
  332. </DropdownMenuItem>
  333. <DropdownMenuItem
  334. className="flex items-center space-x-2 whitespace-nowrap"
  335. onClick={(e) => {
  336. e.stopPropagation()
  337. downloadImage('png')
  338. }}
  339. >
  340. <Download size={12} />
  341. <span>Download as PNG</span>
  342. </DropdownMenuItem>
  343. <DropdownMenuItem
  344. className="flex items-center space-x-2 whitespace-nowrap"
  345. onClick={(e) => {
  346. e.stopPropagation()
  347. downloadImage('svg')
  348. }}
  349. >
  350. <Download size={12} />
  351. <span>Download as SVG</span>
  352. </DropdownMenuItem>
  353. </DropdownMenuContent>
  354. </DropdownMenu>
  355. </div>
  356. <AlertDialog open={autoLayoutDialogOpen} onOpenChange={setAutoLayoutDialogOpen}>
  357. <Shortcut
  358. id={SHORTCUT_IDS.SCHEMA_VISUALIZER_AUTO_LAYOUT}
  359. onTrigger={() => setAutoLayoutDialogOpen(true)}
  360. options={{ enabled: shortcutsEnabled }}
  361. side="bottom"
  362. tooltipOpen={autoLayoutDialogOpen ? false : undefined}
  363. >
  364. <AlertDialogTrigger asChild>
  365. <Button type="default">Auto layout</Button>
  366. </AlertDialogTrigger>
  367. </Shortcut>
  368. <AlertDialogContent>
  369. <AlertDialogHeader>
  370. <AlertDialogTitle>Confirm to rearrange all nodes</AlertDialogTitle>
  371. <AlertDialogDescription>
  372. Auto layout will rearrange all nodes in the graph. This cannot be undone.
  373. </AlertDialogDescription>
  374. </AlertDialogHeader>
  375. <AlertDialogFooter>
  376. <AlertDialogCancel>Cancel</AlertDialogCancel>
  377. <AlertDialogAction onClick={resetLayout}>Apply</AlertDialogAction>
  378. </AlertDialogFooter>
  379. </AlertDialogContent>
  380. </AlertDialog>
  381. </div>
  382. )}
  383. </>
  384. )}
  385. </div>
  386. {isLoadingTables && (
  387. <div className="w-full h-full flex items-center justify-center gap-x-2">
  388. <Loader2 className="animate-spin text-foreground-light" size={16} />
  389. <p className="text-sm text-foreground-light">Loading tables</p>
  390. </div>
  391. )}
  392. {isErrorTables && (
  393. <div className="w-full h-full flex items-center justify-center px-20">
  394. <AlertError subject="Failed to retrieve tables" error={errorTables} />
  395. </div>
  396. )}
  397. {isSuccessTables && (
  398. <>
  399. {hasNoTables ? (
  400. <div className="flex items-center justify-center w-full h-full">
  401. <Admonition
  402. type="default"
  403. className="max-w-md"
  404. title="No tables in schema"
  405. description={
  406. isSchemaLocked
  407. ? `The “${selectedSchema}” schema is managed by Briven and is read-only through
  408. the dashboard.`
  409. : !canUpdateTables
  410. ? 'You need additional permissions to create tables'
  411. : `The “${selectedSchema}” schema doesn’t have any tables.`
  412. }
  413. >
  414. {canAddTables && (
  415. <Button asChild className="mt-2" type="default" icon={<Plus />}>
  416. <Link href={`/project/${ref}/editor?create=table`}>New table</Link>
  417. </Button>
  418. )}
  419. </Admonition>
  420. </div>
  421. ) : (
  422. <SchemaGraphContextProvider value={schemaGraphContext}>
  423. <div className="w-full h-full">
  424. <ReactFlow<Node<TableNodeData>, Edge<EdgeData>>
  425. // FIXME: https://github.com/xyflow/xyflow/issues/4876
  426. colorMode={'' as unknown as ColorMode}
  427. defaultNodes={[]}
  428. defaultEdges={[]}
  429. defaultEdgeOptions={{
  430. type: 'default',
  431. animated: false,
  432. deletable: false,
  433. }}
  434. nodeTypes={nodeTypes}
  435. edgeTypes={edgeTypes}
  436. fitView
  437. minZoom={0.8}
  438. maxZoom={1.8}
  439. proOptions={{ hideAttribution: true }}
  440. onNodeDragStop={saveNodePositions}
  441. onSelectionChange={handleSelectionChange}
  442. >
  443. <Background
  444. gap={16}
  445. className="*:stroke-foreground-muted opacity-25"
  446. variant={BackgroundVariant.Dots}
  447. color={'inherit'}
  448. />
  449. <MiniMap
  450. pannable
  451. zoomable
  452. nodeColor={miniMapNodeColor}
  453. maskColor={miniMapMaskColor}
  454. className="border rounded-md shadow-xs"
  455. />
  456. <SchemaGraphLegend />
  457. </ReactFlow>
  458. </div>
  459. </SchemaGraphContextProvider>
  460. )}
  461. </>
  462. )}
  463. <SidePanelEditor selectedTable={selectedTable ?? undefined} includeColumns />
  464. </>
  465. )
  466. }