Skip to content

Commit a4f6ca6

Browse files
authored
feat: implement YAML export/import functionality (#47)
* feat: implement YAML export/import functionality - Add js-yaml library for proper YAML parsing/generation - Implement YAML export with file download and clipboard copy - Add YAML import support (.yaml/.yml files) - Create import preview dialog with configuration summary - Add automatic backup before import operations - Implement version compatibility checking - Add validation and error handling for YAML files - Update ConfigurationMenu with enhanced export/import options Closes #42 * fix: update tests for YAML export/import functionality - Update persistence tests to expect js-yaml format output - Fix ConfigurationMenu tests with new button text - Add mocks for new YAML functions - Update error message expectations * fix: add persistence mock for ModeToggle tests - Mock saveDashboardMode to avoid async import issues in tests
1 parent 179b423 commit a4f6ca6

7 files changed

Lines changed: 513 additions & 107 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
"@tanstack/react-router-devtools": "^1.122.0",
2929
"@tanstack/react-start": "^1.122.1",
3030
"@tanstack/react-store": "^0.7.1",
31+
"@types/js-yaml": "^4.0.9",
32+
"js-yaml": "^4.1.0",
3133
"react": "^19.0.0",
3234
"react-dom": "^19.0.0",
3335
"react-grid-layout": "^1.5.2",

src/components/ConfigurationMenu.tsx

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,33 @@ import {
55
UploadIcon,
66
ResetIcon,
77
FileIcon,
8-
CodeIcon,
98
ExclamationTriangleIcon,
9+
CopyIcon,
10+
DownloadIcon,
1011
} from '@radix-ui/react-icons'
1112
import {
1213
exportConfigurationToFile,
13-
exportConfigurationAsYAML,
14+
exportConfigurationToYAMLFile,
15+
copyYAMLToClipboard,
1416
importConfigurationFromFile,
1517
clearDashboardConfig,
1618
getStorageInfo,
19+
restoreConfigurationFromBackup,
20+
parseConfigurationFromFile,
1721
} from '../store/persistence'
22+
import { ImportPreviewDialog } from './ImportPreviewDialog'
23+
import type { DashboardConfig } from '../store/types'
1824

1925
export function ConfigurationMenu() {
2026
const [resetDialogOpen, setResetDialogOpen] = useState(false)
2127
const [importError, setImportError] = useState<string | null>(null)
28+
const [importSuccess, setImportSuccess] = useState<string | null>(null)
2229
const [showStorageWarning, setShowStorageWarning] = useState(false)
30+
const [copySuccess, setCopySuccess] = useState(false)
31+
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
32+
const [previewConfig, setPreviewConfig] = useState<DashboardConfig | null>(null)
33+
const [previewVersionMessage, setPreviewVersionMessage] = useState<string | undefined>()
34+
const [pendingFile, setPendingFile] = useState<File | null>(null)
2335
const fileInputRef = useRef<HTMLInputElement>(null)
2436

2537
const handleExportJSON = () => {
@@ -32,19 +44,22 @@ export function ConfigurationMenu() {
3244

3345
const handleExportYAML = () => {
3446
try {
35-
const yaml = exportConfigurationAsYAML()
36-
const blob = new Blob([yaml], { type: 'text/yaml;charset=utf-8' })
37-
const url = URL.createObjectURL(blob)
38-
const link = document.createElement('a')
39-
link.href = url
40-
link.download = `liebe-${new Date().toISOString().split('T')[0]}.yaml`
41-
link.click()
42-
URL.revokeObjectURL(url)
47+
exportConfigurationToYAMLFile()
4348
} catch (error) {
4449
console.error('YAML export failed:', error)
4550
}
4651
}
4752

53+
const handleCopyYAML = async () => {
54+
try {
55+
await copyYAMLToClipboard()
56+
setCopySuccess(true)
57+
setTimeout(() => setCopySuccess(false), 2000)
58+
} catch (error) {
59+
console.error('Copy to clipboard failed:', error)
60+
}
61+
}
62+
4863
const handleImport = () => {
4964
fileInputRef.current?.click()
5065
}
@@ -55,21 +70,52 @@ export function ConfigurationMenu() {
5570

5671
try {
5772
setImportError(null)
58-
await importConfigurationFromFile(file)
73+
setImportSuccess(null)
74+
75+
// Parse the file and show preview
76+
const { config, versionMessage } = await parseConfigurationFromFile(file)
77+
setPreviewConfig(config)
78+
setPreviewVersionMessage(versionMessage)
79+
setPendingFile(file)
80+
setPreviewDialogOpen(true)
81+
} catch (error) {
82+
setImportError((error as Error).message)
83+
}
84+
85+
// Reset file input
86+
if (fileInputRef.current) {
87+
fileInputRef.current.value = ''
88+
}
89+
}
90+
91+
const handleConfirmImport = async () => {
92+
if (!pendingFile) return
93+
94+
try {
95+
await importConfigurationFromFile(pendingFile)
5996

6097
// Check storage after import
6198
const storageInfo = getStorageInfo()
6299
if (!storageInfo.available) {
63100
setShowStorageWarning(true)
64101
}
102+
103+
setImportSuccess('Configuration imported successfully!')
104+
setTimeout(() => setImportSuccess(null), 3000)
105+
setPreviewDialogOpen(false)
106+
setPendingFile(null)
65107
} catch (error) {
66108
setImportError((error as Error).message)
109+
setPreviewDialogOpen(false)
110+
setPendingFile(null)
67111
}
112+
}
68113

69-
// Reset file input
70-
if (fileInputRef.current) {
71-
fileInputRef.current.value = ''
72-
}
114+
const handleCancelImport = () => {
115+
setPreviewDialogOpen(false)
116+
setPendingFile(null)
117+
setPreviewConfig(null)
118+
setPreviewVersionMessage(undefined)
73119
}
74120

75121
const handleReset = () => {
@@ -102,16 +148,20 @@ export function ConfigurationMenu() {
102148
Export as JSON
103149
</DropdownMenu.Item>
104150
<DropdownMenu.Item onClick={handleExportYAML}>
105-
<CodeIcon />
106-
Export as YAML
151+
<DownloadIcon />
152+
Download as YAML
153+
</DropdownMenu.Item>
154+
<DropdownMenu.Item onClick={handleCopyYAML}>
155+
<CopyIcon />
156+
{copySuccess ? 'Copied!' : 'Copy YAML to Clipboard'}
107157
</DropdownMenu.Item>
108158

109159
<DropdownMenu.Separator />
110160

111161
<DropdownMenu.Label>Import Configuration</DropdownMenu.Label>
112162
<DropdownMenu.Item onClick={handleImport}>
113163
<UploadIcon />
114-
Import from File
164+
Import from File (JSON/YAML)
115165
</DropdownMenu.Item>
116166

117167
<DropdownMenu.Separator />
@@ -136,7 +186,7 @@ export function ConfigurationMenu() {
136186
<input
137187
ref={fileInputRef}
138188
type="file"
139-
accept=".json"
189+
accept=".json,.yaml,.yml"
140190
style={{ display: 'none' }}
141191
onChange={handleFileChange}
142192
/>
@@ -147,7 +197,33 @@ export function ConfigurationMenu() {
147197
<Callout.Icon>
148198
<ExclamationTriangleIcon />
149199
</Callout.Icon>
150-
<Callout.Text>{importError}</Callout.Text>
200+
<Callout.Text>
201+
{importError}
202+
{importError.includes('backup') && (
203+
<Button
204+
size="1"
205+
variant="soft"
206+
ml="2"
207+
onClick={() => {
208+
try {
209+
restoreConfigurationFromBackup()
210+
window.location.reload()
211+
} catch (error) {
212+
console.error('Failed to restore backup:', error)
213+
}
214+
}}
215+
>
216+
Restore Backup
217+
</Button>
218+
)}
219+
</Callout.Text>
220+
</Callout.Root>
221+
)}
222+
223+
{/* Import success callout */}
224+
{importSuccess && (
225+
<Callout.Root color="green" mt="2">
226+
<Callout.Text>{importSuccess}</Callout.Text>
151227
</Callout.Root>
152228
)}
153229

@@ -186,6 +262,16 @@ export function ConfigurationMenu() {
186262
</Flex>
187263
</AlertDialog.Content>
188264
</AlertDialog.Root>
265+
266+
{/* Import preview dialog */}
267+
<ImportPreviewDialog
268+
open={previewDialogOpen}
269+
onOpenChange={setPreviewDialogOpen}
270+
config={previewConfig}
271+
versionMessage={previewVersionMessage}
272+
onConfirm={handleConfirmImport}
273+
onCancel={handleCancelImport}
274+
/>
189275
</>
190276
)
191277
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { Dialog, Button, Text, Flex, Box, ScrollArea, Badge, Callout } from '@radix-ui/themes'
2+
import { InfoCircledIcon, CheckCircledIcon } from '@radix-ui/react-icons'
3+
import type { DashboardConfig } from '../store/types'
4+
5+
interface ImportPreviewDialogProps {
6+
open: boolean
7+
onOpenChange: (open: boolean) => void
8+
config: DashboardConfig | null
9+
versionMessage?: string
10+
onConfirm: () => void
11+
onCancel: () => void
12+
}
13+
14+
export function ImportPreviewDialog({
15+
open,
16+
onOpenChange,
17+
config,
18+
versionMessage,
19+
onConfirm,
20+
onCancel,
21+
}: ImportPreviewDialogProps) {
22+
if (!config) return null
23+
24+
const totalScreens = countScreens(config.screens)
25+
const totalGridItems = countGridItems(config.screens)
26+
27+
function countScreens(screens: DashboardConfig['screens']): number {
28+
return screens.reduce((count, screen) => {
29+
return count + 1 + (screen.children ? countScreens(screen.children) : 0)
30+
}, 0)
31+
}
32+
33+
function countGridItems(screens: DashboardConfig['screens']): number {
34+
return screens.reduce((count, screen) => {
35+
const itemCount = screen.grid?.items?.length || 0
36+
const childCount = screen.children ? countGridItems(screen.children) : 0
37+
return count + itemCount + childCount
38+
}, 0)
39+
}
40+
41+
return (
42+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
43+
<Dialog.Content maxWidth="600px">
44+
<Dialog.Title>Import Preview</Dialog.Title>
45+
<Dialog.Description>
46+
Review the configuration before importing. Your current configuration will be backed up
47+
automatically.
48+
</Dialog.Description>
49+
50+
{versionMessage && (
51+
<Callout.Root color="blue" mt="3">
52+
<Callout.Icon>
53+
<InfoCircledIcon />
54+
</Callout.Icon>
55+
<Callout.Text>{versionMessage}</Callout.Text>
56+
</Callout.Root>
57+
)}
58+
59+
<Box mt="4">
60+
<Flex direction="column" gap="3">
61+
<Flex align="center" gap="2">
62+
<Text size="2" weight="medium">
63+
Version:
64+
</Text>
65+
<Badge>{config.version}</Badge>
66+
</Flex>
67+
68+
<Flex align="center" gap="2">
69+
<Text size="2" weight="medium">
70+
Theme:
71+
</Text>
72+
<Badge variant="outline">{config.theme || 'auto'}</Badge>
73+
</Flex>
74+
75+
<Flex align="center" gap="2">
76+
<Text size="2" weight="medium">
77+
Total Screens:
78+
</Text>
79+
<Badge color="blue">{totalScreens}</Badge>
80+
</Flex>
81+
82+
<Flex align="center" gap="2">
83+
<Text size="2" weight="medium">
84+
Total Grid Items:
85+
</Text>
86+
<Badge color="green">{totalGridItems}</Badge>
87+
</Flex>
88+
</Flex>
89+
</Box>
90+
91+
<Box mt="4">
92+
<Text size="2" weight="medium" mb="2">
93+
Screen Structure:
94+
</Text>
95+
<ScrollArea style={{ maxHeight: '200px' }}>
96+
<Box
97+
p="3"
98+
style={{
99+
backgroundColor: 'var(--gray-a2)',
100+
borderRadius: 'var(--radius-2)',
101+
}}
102+
>
103+
{renderScreenTree(config.screens)}
104+
</Box>
105+
</ScrollArea>
106+
</Box>
107+
108+
<Flex gap="3" mt="4" justify="end">
109+
<Dialog.Close>
110+
<Button variant="soft" color="gray" onClick={onCancel}>
111+
Cancel
112+
</Button>
113+
</Dialog.Close>
114+
<Button variant="solid" onClick={onConfirm}>
115+
<CheckCircledIcon />
116+
Import Configuration
117+
</Button>
118+
</Flex>
119+
</Dialog.Content>
120+
</Dialog.Root>
121+
)
122+
}
123+
124+
function renderScreenTree(screens: DashboardConfig['screens'], level = 0): React.ReactElement {
125+
return (
126+
<>
127+
{screens.map((screen) => (
128+
<Box key={screen.id} ml={level > 0 ? '4' : '0'}>
129+
<Flex align="center" gap="2" mb="1">
130+
<Text size="2">
131+
{level > 0 && '└─ '}
132+
{screen.name}
133+
</Text>
134+
{screen.grid?.items && screen.grid.items.length > 0 && (
135+
<Badge size="1" variant="soft">
136+
{screen.grid.items.length} items
137+
</Badge>
138+
)}
139+
</Flex>
140+
{screen.children && renderScreenTree(screen.children, level + 1)}
141+
</Box>
142+
))}
143+
</>
144+
)
145+
}

0 commit comments

Comments
 (0)