Skip to content

Commit 3c22b70

Browse files
authored
Merge pull request #3988 from liam-hq/feat/export-dropdown-in-erd-header
✨ feat(erd-core): Add Export dropdown to copy PostgreSQL DDL and YAML from the ERD
2 parents d601e13 + fdc66cd commit 3c22b70

File tree

8 files changed

+255
-5
lines changed

8 files changed

+255
-5
lines changed

.changeset/stale-chefs-bake.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@liam-hq/erd-core": patch
3+
---
4+
5+
- ✨ Add Export dropdown to copy PostgreSQL DDL and YAML from the ERD
6+
- Adds ExportDropdown to the AppBar with “Copy PostgreSQL” and “Copy YAML” actions, using schema deparsers and clipboard with success/error toasts; adds @liam-hq/neverthrow dependency

frontend/packages/erd-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"./nextjs": "./src/nextjs/index.ts"
1616
},
1717
"dependencies": {
18+
"@liam-hq/neverthrow": "workspace:*",
1819
"@liam-hq/ui": "workspace:*",
1920
"@radix-ui/react-dialog": "1.1.15",
2021
"@radix-ui/react-toolbar": "1.1.11",

frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/AppBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { FC } from 'react'
1010
import { CommandPaletteTriggerButton } from '../CommandPalette'
1111
import styles from './AppBar.module.css'
1212
import { CopyLinkButton } from './CopyLinkButton'
13+
import { ExportDropdown } from './ExportDropdown'
1314
import { GithubButton } from './GithubButton'
1415
import { HelpButton } from './HelpButton'
1516
import { MenuButton } from './MenuButton'
@@ -50,8 +51,7 @@ export const AppBar: FC = () => {
5051
<ReleaseNoteButton />
5152
<HelpButton />
5253
</div>
53-
{/* TODO: enable once implemented */}
54-
{/* <ExportButton /> */}
54+
<ExportDropdown />
5555
<CopyLinkButton />
5656
</div>
5757
</header>

frontend/packages/erd-core/src/features/erd/components/ERDRenderer/AppBar/ExportButton/ExportButton.module.css

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { aSchema, aTable } from '@liam-hq/schema'
2+
import { ToastProvider } from '@liam-hq/ui'
3+
import { render, screen } from '@testing-library/react'
4+
import userEvent from '@testing-library/user-event'
5+
import type { FC, PropsWithChildren } from 'react'
6+
import { describe, expect, it, vi } from 'vitest'
7+
import { SchemaProvider } from '../../../../../../stores'
8+
import { ExportDropdown } from './ExportDropdown'
9+
10+
const wrapper: FC<PropsWithChildren> = ({ children }) => (
11+
<ToastProvider>
12+
<SchemaProvider
13+
current={aSchema({ tables: { users: aTable({ name: 'users' }) } })}
14+
>
15+
{children}
16+
</SchemaProvider>
17+
</ToastProvider>
18+
)
19+
20+
describe('PostgreSQL export', () => {
21+
it('should handle successful PostgreSQL DDL copy', async () => {
22+
const user = userEvent.setup()
23+
render(<ExportDropdown />, { wrapper })
24+
25+
await user.click(screen.getByRole('button'))
26+
await user.click(screen.getByText('Copy PostgreSQL'))
27+
28+
const clipboard = await navigator.clipboard.readText()
29+
expect(clipboard).toContain('CREATE TABLE "users"') // check clipboard content, should contain DDL for users table
30+
31+
// check toast
32+
expect(
33+
await screen.findByText('PostgreSQL DDL copied!'),
34+
).toBeInTheDocument()
35+
expect(
36+
await screen.findByText('Schema DDL has been copied to clipboard'),
37+
).toBeInTheDocument()
38+
})
39+
40+
it('should show error toast if clipboard write fails', async () => {
41+
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(
42+
new Error('Clipboard write failed'),
43+
)
44+
45+
const user = userEvent.setup()
46+
render(<ExportDropdown />, { wrapper })
47+
48+
await user.click(screen.getByRole('button'))
49+
await user.click(screen.getByText('Copy PostgreSQL'))
50+
51+
// check toast
52+
expect(await screen.findByText('Copy failed')).toBeInTheDocument()
53+
expect(
54+
await screen.findByText(
55+
'Failed to copy DDL to clipboard: Clipboard write failed',
56+
),
57+
).toBeInTheDocument()
58+
})
59+
60+
// TODO: add test case for clipboard API unavailable, currently not possible to remove navigator.clipboard in jsdom
61+
it.todo('should handle unavailable clipboard API')
62+
63+
// TODO: add test case for DDL generation failure when deparser supports error cases
64+
it.todo('should show error toast if PostgreSQL DDL generation fails')
65+
})
66+
67+
describe('YAML export', () => {
68+
it('should handle successful YAML copy', async () => {
69+
const user = userEvent.setup()
70+
render(<ExportDropdown />, { wrapper })
71+
72+
await user.click(screen.getByRole('button'))
73+
await user.click(screen.getByText('Copy YAML'))
74+
75+
const clipboard = await navigator.clipboard.readText()
76+
expect(clipboard).toContain('tables:\n users:') // check clipboard content, should contain YAML for users table
77+
78+
// check toast
79+
expect(await screen.findByText('YAML copied!')).toBeInTheDocument()
80+
expect(
81+
await screen.findByText('Schema YAML has been copied to clipboard'),
82+
).toBeInTheDocument()
83+
})
84+
85+
it('should show error toast if clipboard write fails', async () => {
86+
vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValueOnce(
87+
new Error('Clipboard write failed'),
88+
)
89+
90+
const user = userEvent.setup()
91+
render(<ExportDropdown />, { wrapper })
92+
93+
await user.click(screen.getByRole('button'))
94+
await user.click(screen.getByText('Copy YAML'))
95+
96+
// check toast
97+
expect(await screen.findByText('Copy failed')).toBeInTheDocument()
98+
expect(
99+
await screen.findByText(
100+
'Failed to copy YAML to clipboard: Clipboard write failed',
101+
),
102+
).toBeInTheDocument()
103+
})
104+
105+
// TODO: add test case for clipboard API unavailable, currently not possible to remove navigator.clipboard in jsdom
106+
it.todo('should handle unavailable clipboard API')
107+
108+
it.todo('should show error toast if YAML generation fails')
109+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { fromPromise } from '@liam-hq/neverthrow'
2+
import { postgresqlSchemaDeparser, yamlSchemaDeparser } from '@liam-hq/schema'
3+
import {
4+
Button,
5+
ChevronDown,
6+
Copy,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuPortal,
10+
DropdownMenuRoot,
11+
DropdownMenuTrigger,
12+
useToast,
13+
} from '@liam-hq/ui'
14+
import type { FC } from 'react'
15+
import { useSchemaOrThrow } from '../../../../../../stores'
16+
17+
export const ExportDropdown: FC = () => {
18+
const toast = useToast()
19+
const schema = useSchemaOrThrow()
20+
21+
const handleCopyPostgreSQL = async () => {
22+
// Feature detection for clipboard API
23+
if (!navigator.clipboard || !navigator.clipboard?.writeText) {
24+
toast({
25+
title: 'Clipboard unavailable',
26+
status: 'error',
27+
})
28+
return
29+
}
30+
31+
const result = postgresqlSchemaDeparser(schema.current)
32+
const ddl = result.value ? `${result.value}\n` : ''
33+
34+
const clipboardResult = await fromPromise(
35+
navigator.clipboard.writeText(ddl),
36+
)
37+
38+
clipboardResult.match(
39+
() => {
40+
toast({
41+
title: 'PostgreSQL DDL copied!',
42+
description: 'Schema DDL has been copied to clipboard',
43+
status: 'success',
44+
})
45+
},
46+
(error: Error) => {
47+
console.error('Failed to copy PostgreSQL DDL to clipboard:', error)
48+
toast({
49+
title: 'Copy failed',
50+
description: `Failed to copy DDL to clipboard: ${error.message}`,
51+
status: 'error',
52+
})
53+
},
54+
)
55+
}
56+
57+
const handleCopyYaml = async () => {
58+
// Feature detection for clipboard API
59+
if (!navigator.clipboard || !navigator.clipboard?.writeText) {
60+
toast({
61+
title: 'Clipboard unavailable',
62+
status: 'error',
63+
})
64+
return
65+
}
66+
67+
const yamlResult = yamlSchemaDeparser(schema.current)
68+
69+
if (yamlResult.isErr()) {
70+
const error = yamlResult.error
71+
console.error('Failed to generate YAML:', error)
72+
toast({
73+
title: 'Export failed',
74+
description: `Failed to generate YAML: ${error.message}`,
75+
status: 'error',
76+
})
77+
return
78+
}
79+
80+
const yamlContent = yamlResult.value
81+
const clipboardResult = await fromPromise(
82+
navigator.clipboard.writeText(yamlContent),
83+
)
84+
85+
clipboardResult.match(
86+
() => {
87+
toast({
88+
title: 'YAML copied!',
89+
description: 'Schema YAML has been copied to clipboard',
90+
status: 'success',
91+
})
92+
},
93+
(error: Error) => {
94+
console.error('Failed to copy YAML to clipboard:', error)
95+
toast({
96+
title: 'Copy failed',
97+
description: `Failed to copy YAML to clipboard: ${error.message}`,
98+
status: 'error',
99+
})
100+
},
101+
)
102+
}
103+
104+
return (
105+
<DropdownMenuRoot>
106+
<DropdownMenuTrigger asChild>
107+
<Button
108+
variant="outline-secondary"
109+
size="md"
110+
rightIcon={<ChevronDown size={16} />}
111+
>
112+
Export
113+
</Button>
114+
</DropdownMenuTrigger>
115+
<DropdownMenuPortal>
116+
<DropdownMenuContent align="end" sideOffset={8}>
117+
<DropdownMenuItem
118+
leftIcon={<Copy size={16} />}
119+
onSelect={handleCopyPostgreSQL}
120+
>
121+
Copy PostgreSQL
122+
</DropdownMenuItem>
123+
<DropdownMenuItem
124+
leftIcon={<Copy size={16} />}
125+
onSelect={handleCopyYaml}
126+
>
127+
Copy YAML
128+
</DropdownMenuItem>
129+
</DropdownMenuContent>
130+
</DropdownMenuPortal>
131+
</DropdownMenuRoot>
132+
)
133+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './ExportDropdown'

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)