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