feat: Add "Export as markdown" action on both the schema and individual tables (#44986)

## Problem

When using LLMs, it's useful to describe your tables in markdown format.

## Solution

- Add an _Copy as SQL_ and _Copy as Markdown_ in the schema visualiser
table menu
<img width="320" height="235" alt="image"
src="https://github.com/user-attachments/assets/b465d6aa-a011-4308-86de-78725328630b"
/>

- Refactor the _Copy as SQL_ and _Download current view_ buttons in a
single button/dropdown combo and add _Copy as markdown_:
<img width="333" height="143" alt="image"
src="https://github.com/user-attachments/assets/a823988b-abff-4840-b5a5-53a5830065b4"
/>



<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * "Copy as Markdown" for schemas and individual tables.
  * "Copy as SQL" for individual tables.
  * Per-column descriptions included in schema/table exports.

* **Style**
* Export actions consolidated into a compact, grouped dropdown with
adjacent copy action for streamlined header controls.

* **Tests**
  * Unit tests for markdown export helpers.
* E2E tests updated to use the new export UI and adjusted dialog timing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Gildas GarciaandCopilot Autofix powered by AI authored and GitHub committed 2026-04-21 16:09:54 +02:00
1 parent e8e0842be3
commit 19a6fc90ac
6 files changed
+374 -61

No files matched your search

@@ -12,7 +12,7 @@ import {
useReactFlow,
} from '@xyflow/react'
import { toPng, toSvg } from 'html-to-image'
import { Check, Copy, Download, Loader2, Plus } from 'lucide-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'
@@ -45,7 +45,11 @@ import { DefaultEdge } from './DefaultEdge'
import { SchemaGraphContextProvider, SchemaGraphContextType } from './SchemaGraphContext'
import { SchemaGraphLegend } from './SchemaGraphLegend'
import { EdgeData, TableNodeData } from './Schemas.constants'
import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils'
import {
getGraphDataFromTables,
getLayoutedElementsViaDagre,
getSchemaAsMarkdown,
} from './Schemas.utils'
import { TableNode } from './SchemaTableNode'
import { useExportSchemaToImage } from './useExportSchemaToImage'
import AlertError from '@/components/ui/AlertError'
@@ -269,52 +273,85 @@ export const SchemaGraph = () => {
/>
{!hasNoTables && (
<div className="flex items-center gap-x-2">
<ButtonTooltip
type="outline"
icon={copied ? <Check data-testid="copy-sql-ready" /> : <Copy />}
onClick={() => {
if (tables) {
copyToClipboard(tablesToSQL(tables))
setCopied(true)
}
}}
tooltip={{
content: {
side: 'bottom',
text: (
<div className="max-w-[180px] space-y-2 text-foreground-light">
<p className="text-foreground">Note</p>
<p>
This schema is for context or debugging only. Table order and
constraints may be invalid. Not meant to be run as-is.
</p>
</div>
),
},
}}
>
Copy as SQL
</ButtonTooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonTooltip
aria-label="Download Schema"
type="default"
loading={isDownloading}
className="px-1.5"
icon={<Download />}
tooltip={{ content: { side: 'bottom', text: 'Download current view' } }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-32">
<DropdownMenuItem onClick={() => downloadImage('png')}>
Download as PNG
</DropdownMenuItem>
<DropdownMenuItem onClick={() => downloadImage('svg')}>
Download as SVG
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-center gap-0">
<ButtonTooltip
type="default"
className="rounded-r-none border-r-0"
icon={copied ? <Check data-testid="copy-sql-ready" /> : <Copy />}
onClick={() => {
if (tables) {
copyToClipboard(tablesToSQL(tables))
setCopied(true)
}
}}
tooltip={{
content: {
side: 'bottom',
text: (
<div className="max-w-[180px] space-y-2 text-foreground-light">
<p className="text-foreground">Note</p>
<p>
This schema is for context or debugging only. Table order and
constraints may be invalid. Not meant to be run as-is.
</p>
</div>
),
},
}}
>
Copy as SQL
</ButtonTooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
size="tiny"
className="rounded-l-none pl-1 pr-0"
icon={<ChevronDown size={12} />}
>
<span className="sr-only">Export options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
const tables = reactFlowInstance
.getNodes()
.filter((node) => node.type === 'table')
.map((node) => node.data as TableNodeData)
copyToClipboard(getSchemaAsMarkdown(selectedSchema, tables))
setCopied(true)
}}
>
<Copy size={12} />
<span>Copy as Markdown</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
downloadImage('png')
}}
>
<Download size={12} />
<span>Download as PNG</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
downloadImage('svg')
}}
>
<Download size={12} />
<span>Download as SVG</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<ButtonTooltip
@@ -13,6 +13,7 @@ import {
Table2,
} from 'lucide-react'
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import {
Button,
cn,
@@ -20,6 +21,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Tooltip,
TooltipContent,
@@ -28,9 +30,12 @@ import {
import { useSchemaGraphContext } from './SchemaGraphContext'
import { TableNodeData } from './Schemas.constants'
import { getTableDefinitionAsMarkdown } from './Schemas.utils'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
import { getTableDefinition } from '@/data/database/table-definition-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
// ReactFlow is scaling everything by the factor of 2
export const TABLE_NODE_WIDTH = 320
@@ -126,16 +131,6 @@ export const TableNode = ({
<Edit size={12} />
<p>Edit table</p>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
copyToClipboard(data.name)
}}
>
<Copy size={12} />
<span>Copy name</span>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={() =>
@@ -151,6 +146,73 @@ export const TableNode = ({
<TableEditor size={12} />
<p>View in Table Editor</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center space-x-2 whitespace-nowrap"
onClick={(e) => {
e.stopPropagation()
copyToClipboard(data.name)
}}
>
<Copy size={12} />
<span>Copy name</span>
</DropdownMenuItem>
<DropdownMenuItem
key="copy-schema-sql"
className="space-x-2"
onClick={async (e) => {
e.stopPropagation()
const toastId = toast.loading('Getting table schema...')
const formattedSchema = getTableDefinition({
id: data.id,
projectRef: project?.ref,
connectionString: project?.connectionString,
}).then((tableDefinition) => {
if (!tableDefinition) {
throw new Error('Failed to get table schema')
}
return formatSql(tableDefinition)
})
try {
await copyToClipboard(formattedSchema, () => {
toast.success('Table schema copied to clipboard', { id: toastId })
})
} catch (err) {
toast.error(
'Failed to copy schema: ' + ((err as Error).message || err),
{
id: toastId,
}
)
}
}}
>
<Copy size={12} />
<span>Copy as SQL</span>
</DropdownMenuItem>
<DropdownMenuItem
key="copy-schema-markdown"
className="space-x-2"
onClick={async (e) => {
e.stopPropagation()
const markdown = getTableDefinitionAsMarkdown(data)
try {
await copyToClipboard(markdown, () => {
toast.success('Table schema copied to clipboard')
})
} catch (err) {
toast.error(
'Failed to copy schema: ' + ((err as Error).message || err)
)
}
}}
>
<Copy size={12} />
<span>Copy as Markdown</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -13,6 +13,7 @@ export type TableNodeData = {
isIdentity: boolean
name: string
format: string
description: string
}[]
}
@@ -0,0 +1,176 @@
import { describe, expect, test } from 'vitest'
import { getSchemaAsMarkdown, getTableDefinitionAsMarkdown } from './Schemas.utils'
describe('Schemas.utils', () => {
test('getSchemaAsMarkdown returns properly formatted markdown', () => {
const schema = 'public'
const tables = [
{
ref: 'default',
id: 20999,
name: 'test',
description: 'An excellent description',
schema: 'public',
isForeign: false,
columns: [
{
id: '20999.1',
isPrimary: true,
name: 'id',
format: 'int8',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: true,
description: '',
},
{
id: '20999.2',
isPrimary: false,
name: 'created_at',
format: 'timestamptz',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: false,
description: '',
},
{
id: '20999.3',
isPrimary: false,
name: 'user_id',
format: 'uuid',
isNullable: true,
isUnique: false,
isUpdateable: true,
isIdentity: false,
description: '',
},
],
},
{
ref: 'default',
id: 21049,
name: 'test2',
description: '',
schema: 'public',
isForeign: false,
columns: [
{
id: '21049.1',
isPrimary: true,
name: 'id',
format: 'int8',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: true,
description: '',
},
{
id: '21049.2',
isPrimary: false,
name: 'created_at',
format: 'timestamptz',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: false,
description: '',
},
],
},
{
id: 21009,
ref: 'default',
schema: 'auth',
name: 'auth.users.id',
description: '',
isForeign: true,
columns: [],
},
]
const result = getSchemaAsMarkdown(schema, tables)
expect(result).toBe(`## Table \`test\`
An excellent description
### Columns
| Name | Type | Constraints |
|------|------|-------------|
| \`id\` | \`int8\` | Primary Identity |
| \`created_at\` | \`timestamptz\` | |
| \`user_id\` | \`uuid\` | Nullable |
## Table \`test2\`
### Columns
| Name | Type | Constraints |
|------|------|-------------|
| \`id\` | \`int8\` | Primary Identity |
| \`created_at\` | \`timestamptz\` | |
`)
})
test('getTableDefinitionAsMarkdown returns properly formatted markdown', () => {
const table = {
ref: 'default',
id: 20999,
name: 'test',
description: 'An excellent description',
schema: 'public',
isForeign: false,
columns: [
{
id: '20999.1',
isPrimary: true,
name: 'id',
format: 'int8',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: true,
description: '',
},
{
id: '20999.2',
isPrimary: false,
name: 'created_at',
format: 'timestamptz',
isNullable: false,
isUnique: false,
isUpdateable: true,
isIdentity: false,
description: '',
},
{
id: '20999.3',
isPrimary: false,
name: 'user_id',
format: 'uuid',
isNullable: true,
isUnique: false,
isUpdateable: true,
isIdentity: false,
description: '',
},
],
}
const result = getTableDefinitionAsMarkdown(table)
expect(result).toBe(`## Table \`test\`
An excellent description
### Columns
| Name | Type | Constraints |
|------|------|-------------|
| \`id\` | \`int8\` | Primary Identity |
| \`created_at\` | \`timestamptz\` | |
| \`user_id\` | \`uuid\` | Nullable |
`)
})
})
@@ -37,6 +37,7 @@ export async function getGraphDataFromTables(
isUnique: column.is_unique,
isUpdateable: column.is_updatable,
isIdentity: column.is_identity,
description: column.comment ?? '',
}
})
@@ -255,3 +256,39 @@ const getLayoutedElementsViaLocalStorage = (
})
return { nodes, edges }
}
export const getTableDefinitionAsMarkdown = (table: TableNodeData) => {
let markdown = `## Table \`${escapeForMarkdown(table.name)}\`\n\n`
if (table.description) {
markdown += `${table.description}\n\n`
}
markdown += `### Columns\n\n`
markdown += `| Name | Type | Constraints |\n`
markdown += `|------|------|-------------|\n`
return table.columns.reduce((current, column) => {
current += `| \`${escapeForMarkdown(column.name)}\` | \`${escapeForMarkdown(column.format)}\` | ${column.isPrimary ? 'Primary' : ''}${column.isNullable ? ' Nullable' : ''}${column.isUnique ? ' Unique' : ''}${column.isIdentity ? ' Identity' : ''} |\n`
return current
}, markdown)
}
export const getSchemaAsMarkdown = (schema: string, tables: TableNodeData[]) => {
return tables.reduce((current, table) => {
if (table.schema === schema) {
current += `${getTableDefinitionAsMarkdown(table)}\n`
}
return current
}, '')
}
const escapeForMarkdown = (str: string) => {
return (
str
// Escape backslashes first so later escapes are not ambiguous
.replace(/\\/g, '\\\\')
// Escape backticks and pipes for markdown tables
.replace(/([|`])/g, '\\$1')
// Remove new lines
.replace(/\n/g, ' ')
)
}
+3 -3
View File
@@ -52,7 +52,7 @@ test.describe('Database', () => {
// downloads schema diagram when export is triggered
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Download Schema' }).click()
await page.getByRole('button', { name: 'Export options' }).click()
await page.getByRole('menuitem', { name: 'Download as PNG' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toContain('.png')
@@ -100,6 +100,8 @@ test.describe('Database', () => {
const dialog = page.getByRole('dialog')
await expect(dialog).toBeVisible()
await expect(dialog.getByText('timestamptz')).toBeVisible()
// FIXME: For some reason, the dialog is not stable and rerenders, sometimes preventing the description to be filled
await page.waitForTimeout(500)
await page.getByLabel('Description').fill('Bazinga')
await page.getByRole('button', { name: 'Save' }).click()
await expect(page.getByText(`Successfully updated ${databaseTableName}!`)).toBeVisible()
@@ -111,8 +113,6 @@ test.describe('Database', () => {
await editTableMenuItem.press('Enter')
await expect(editTableMenuItem).not.toBeVisible()
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()