feat: add flow export/import frontend + backend tests
Frontend: - ExportFlowModal with JSON/XML format selection + download - ImportFlowModal with drag-drop file picker + preview step - rfflowParser for client-side JSON/XML .rfflow parsing - Export buttons on editor toolbar and library action menus - Import button on library page next to Create New - Provenance display for imported flows in editor - flowTransfer API client + types Backend: - Fix regex->pattern deprecation in export endpoint - 12 integration tests covering export, import, round-trip, access control, tag/category creation, version validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
18
frontend/src/api/flowTransfer.ts
Normal file
18
frontend/src/api/flowTransfer.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import apiClient from './client'
|
||||
import type { RFFlowFile, FlowImportResponse } from '@/types'
|
||||
|
||||
export const flowTransferApi = {
|
||||
async exportFlow(treeId: string, format: 'json' | 'xml' = 'json'): Promise<Blob> {
|
||||
const response = await apiClient.get(`/trees/${treeId}/export`, {
|
||||
params: { format },
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async importFlow(data: RFFlowFile, nameOverride?: string): Promise<FlowImportResponse> {
|
||||
const params = nameOverride ? { name_override: nameOverride } : undefined
|
||||
const response = await apiClient.post('/trees/import', data, { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -20,3 +20,4 @@ export { default as aiBuilderApi } from './aiBuilder'
|
||||
export { default as aiChatApi } from './aiChat'
|
||||
export { copilotApi } from './copilot'
|
||||
export { assistantChatApi } from './assistantChat'
|
||||
export { flowTransferApi } from './flowTransfer'
|
||||
|
||||
132
frontend/src/components/library/ExportFlowModal.tsx
Normal file
132
frontend/src/components/library/ExportFlowModal.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Download, X } from 'lucide-react'
|
||||
import { flowTransferApi } from '@/api/flowTransfer'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ExportFlowModalProps {
|
||||
treeId: string
|
||||
treeName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[-\s]+/g, '-')
|
||||
}
|
||||
|
||||
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
|
||||
const [format, setFormat] = useState<'json' | 'xml'>('json')
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const blob = await flowTransferApi.exportFlow(treeId, format)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${slugify(treeName)}.rfflow`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Flow exported successfully')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err)
|
||||
toast.error('Failed to export flow')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Export Flow</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Export <span className="font-medium text-foreground">{treeName}</span> as an .rfflow file.
|
||||
</p>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="mb-1.5 text-xs font-medium text-muted-foreground">Format</legend>
|
||||
{(['json', 'xml'] as const).map((fmt) => (
|
||||
<label
|
||||
key={fmt}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors',
|
||||
format === fmt
|
||||
? 'border-primary/40 bg-primary/5'
|
||||
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="export-format"
|
||||
value={fmt}
|
||||
checked={format === fmt}
|
||||
onChange={() => setFormat(fmt)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-foreground">{fmt.toUpperCase()}</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{fmt === 'json' ? 'Recommended — standard format' : 'XML with JSON tree structure'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{isExporting ? 'Exporting…' : 'Download .rfflow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileUp, X, AlertTriangle } from 'lucide-react'
|
||||
import { flowTransferApi } from '@/api/flowTransfer'
|
||||
import { parseRFFlowFile, RFFlowParseError } from '@/lib/rfflowParser'
|
||||
import type { RFFlowFile } from '@/types'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
|
||||
interface ImportFlowModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
troubleshooting: 'Troubleshooting',
|
||||
procedural: 'Project',
|
||||
maintenance: 'Maintenance',
|
||||
}
|
||||
|
||||
export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [step, setStep] = useState<'pick' | 'preview'>('pick')
|
||||
const [parsed, setParsed] = useState<RFFlowFile | null>(null)
|
||||
const [nameOverride, setNameOverride] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setParseError(null)
|
||||
|
||||
if (!file.name.endsWith('.rfflow')) {
|
||||
setParseError('File must have .rfflow extension')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = parseRFFlowFile(text)
|
||||
setParsed(data)
|
||||
setNameOverride(data.flow.name)
|
||||
setStep('preview')
|
||||
} catch (err) {
|
||||
if (err instanceof RFFlowParseError) {
|
||||
setParseError(err.message)
|
||||
} else {
|
||||
setParseError('Failed to read file')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!parsed) return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const overrideName = nameOverride.trim() !== parsed.flow.name ? nameOverride.trim() : undefined
|
||||
const result = await flowTransferApi.importFlow(parsed, overrideName)
|
||||
toast.success(`Imported "${result.name}" as draft`)
|
||||
onClose()
|
||||
navigate(getTreeEditorPath(result.tree_id, result.tree_type))
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
toast.error('Failed to import flow')
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileUp className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Import Flow</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{step === 'pick' && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FileUp className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-foreground">
|
||||
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">JSON or XML format</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".rfflow"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{parseError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-400" />
|
||||
<p className="text-xs text-rose-400">{parseError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && parsed && (
|
||||
<div className="space-y-4">
|
||||
{/* Editable name */}
|
||||
<div>
|
||||
<label htmlFor="import-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="import-name"
|
||||
type="text"
|
||||
value={nameOverride}
|
||||
onChange={(e) => setNameOverride(e.target.value)}
|
||||
maxLength={255}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flow info */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 font-label text-primary">
|
||||
{TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{parsed.flow.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<p className="mt-0.5 text-foreground line-clamp-2">{parsed.flow.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.category && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Category:</span>
|
||||
<span className="text-foreground">{parsed.flow.category.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.tags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Tags:</span>
|
||||
{parsed.flow.tags.map((tag) => (
|
||||
<span key={tag} className="rounded bg-card border border-border px-2 py-0.5 font-label text-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.author_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Original author:</span>
|
||||
<span className="text-foreground">{parsed.flow.author_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="text-foreground">v{parsed.flow.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Flow will be imported as a <span className="font-medium text-foreground">draft</span>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
{step === 'preview' && (
|
||||
<button
|
||||
onClick={() => { setStep('pick'); setParsed(null); setParseError(null) }}
|
||||
className="mr-auto rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{step === 'preview' && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !nameOverride.trim()}
|
||||
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
{isImporting ? 'Importing…' : 'Import as Draft'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,7 @@ interface TreeGridViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -24,6 +25,7 @@ export function TreeGridView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -111,6 +113,20 @@ export function TreeGridView({
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,7 @@ interface TreeListViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -24,6 +25,7 @@ export function TreeListView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -115,6 +117,20 @@ export function TreeListView({
|
||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,6 +15,7 @@ interface TreeTableViewProps {
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onSortChange?: (sortBy: string) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -29,6 +30,7 @@ export function TreeTableView({
|
||||
onDeleteTree,
|
||||
onSortChange,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -226,6 +228,20 @@ export function TreeTableView({
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
153
frontend/src/lib/rfflowParser.ts
Normal file
153
frontend/src/lib/rfflowParser.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type { RFFlowFile, FlowExportData, FlowExportCategory } from '@/types'
|
||||
|
||||
export class RFFlowParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RFFlowParseError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRFFlowFile(content: string): RFFlowFile {
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (trimmed.startsWith('{')) {
|
||||
return parseJSON(trimmed)
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<rfflow')) {
|
||||
return parseXML(trimmed)
|
||||
}
|
||||
|
||||
throw new RFFlowParseError('Unrecognized file format. Expected JSON or XML.')
|
||||
}
|
||||
|
||||
function parseJSON(content: string): RFFlowFile {
|
||||
try {
|
||||
const data = JSON.parse(content)
|
||||
validateEnvelope(data)
|
||||
return data as RFFlowFile
|
||||
} catch (err) {
|
||||
if (err instanceof RFFlowParseError) throw err
|
||||
throw new RFFlowParseError(`Invalid JSON: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseXML(content: string): RFFlowFile {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(content, 'application/xml')
|
||||
|
||||
const parseError = doc.querySelector('parsererror')
|
||||
if (parseError) {
|
||||
throw new RFFlowParseError('Invalid XML file')
|
||||
}
|
||||
|
||||
const root = doc.documentElement
|
||||
if (root.tagName !== 'rfflow') {
|
||||
throw new RFFlowParseError('Root element must be <rfflow>')
|
||||
}
|
||||
|
||||
const getText = (parent: Element, tag: string): string => {
|
||||
const el = parent.querySelector(`:scope > ${tag}`)
|
||||
return el?.textContent?.trim() ?? ''
|
||||
}
|
||||
|
||||
const rfflowVersion = root.getAttribute('version') || getText(root, 'rfflow_version') || '1.0'
|
||||
const exportedAt = getText(root, 'exported_at')
|
||||
const sourceApp = getText(root, 'source_app') || 'ResolutionFlow'
|
||||
|
||||
const flowEl = root.querySelector(':scope > flow')
|
||||
if (!flowEl) {
|
||||
throw new RFFlowParseError('Missing <flow> element')
|
||||
}
|
||||
|
||||
// Parse category
|
||||
let category: FlowExportCategory | null = null
|
||||
const catEl = flowEl.querySelector(':scope > category')
|
||||
if (catEl) {
|
||||
const catName = getText(catEl, 'name')
|
||||
const catSlug = getText(catEl, 'slug')
|
||||
if (catName && catSlug) {
|
||||
category = { name: catName, slug: catSlug }
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
const tags: string[] = []
|
||||
const tagsEl = flowEl.querySelector(':scope > tags')
|
||||
if (tagsEl) {
|
||||
tagsEl.querySelectorAll(':scope > tag').forEach((tagEl) => {
|
||||
const val = tagEl.textContent?.trim()
|
||||
if (val) tags.push(val)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse tree_structure (stored as JSON string)
|
||||
const tsText = getText(flowEl, 'tree_structure')
|
||||
let treeStructure: Record<string, unknown>
|
||||
try {
|
||||
treeStructure = JSON.parse(tsText)
|
||||
} catch {
|
||||
throw new RFFlowParseError('Invalid tree_structure JSON in XML')
|
||||
}
|
||||
|
||||
// Parse intake_form (optional, stored as JSON string)
|
||||
let intakeForm: Record<string, unknown>[] | null = null
|
||||
const ifText = getText(flowEl, 'intake_form')
|
||||
if (ifText) {
|
||||
try {
|
||||
intakeForm = JSON.parse(ifText)
|
||||
} catch {
|
||||
throw new RFFlowParseError('Invalid intake_form JSON in XML')
|
||||
}
|
||||
}
|
||||
|
||||
const flow: FlowExportData = {
|
||||
name: getText(flowEl, 'name'),
|
||||
description: getText(flowEl, 'description') || null,
|
||||
tree_type: getText(flowEl, 'tree_type') as FlowExportData['tree_type'],
|
||||
version: parseInt(getText(flowEl, 'version') || '1', 10),
|
||||
author_name: getText(flowEl, 'author_name') || null,
|
||||
category,
|
||||
tags,
|
||||
tree_structure: treeStructure,
|
||||
intake_form: intakeForm,
|
||||
}
|
||||
|
||||
const result: RFFlowFile = {
|
||||
rfflow_version: rfflowVersion,
|
||||
exported_at: exportedAt,
|
||||
source_app: sourceApp,
|
||||
format: 'xml',
|
||||
flow,
|
||||
}
|
||||
|
||||
validateEnvelope(result)
|
||||
return result
|
||||
}
|
||||
|
||||
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
||||
const obj = data as Record<string, unknown>
|
||||
|
||||
if (!obj.rfflow_version) {
|
||||
throw new RFFlowParseError('Missing rfflow_version')
|
||||
}
|
||||
if (obj.rfflow_version !== '1.0') {
|
||||
throw new RFFlowParseError(`Unsupported version: ${obj.rfflow_version}. Only 1.0 is supported.`)
|
||||
}
|
||||
|
||||
const flow = obj.flow as Record<string, unknown> | undefined
|
||||
if (!flow) {
|
||||
throw new RFFlowParseError('Missing flow data')
|
||||
}
|
||||
if (!flow.name) {
|
||||
throw new RFFlowParseError('Flow must have a name')
|
||||
}
|
||||
if (!flow.tree_structure) {
|
||||
throw new RFFlowParseError('Flow must have a tree_structure')
|
||||
}
|
||||
|
||||
const validTypes = ['troubleshooting', 'procedural', 'maintenance']
|
||||
if (!validTypes.includes(flow.tree_type as string)) {
|
||||
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||
@@ -16,6 +16,7 @@ import { Spinner } from '@/components/common/Spinner'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||
|
||||
/** Recursively check if any node in the tree has type 'answer' */
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
@@ -61,6 +62,8 @@ export function TreeEditorPage() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [isFixing, setIsFixing] = useState(false)
|
||||
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [importMetadata, setImportMetadata] = useState<Record<string, string | null> | null>(null)
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -166,6 +169,7 @@ export function TreeEditorPage() {
|
||||
}
|
||||
loadTree(tree)
|
||||
setTreeStatus(tree.status) // Load status from existing tree
|
||||
if (tree.import_metadata) setImportMetadata(tree.import_metadata)
|
||||
} catch (err) {
|
||||
console.error('Failed to load tree:', err)
|
||||
toast.error('Failed to load flow')
|
||||
@@ -687,6 +691,20 @@ export function TreeEditorPage() {
|
||||
)}
|
||||
|
||||
{/* Validate */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
title="Export flow as .rfflow file"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleManualValidate}
|
||||
disabled={isSaving}
|
||||
@@ -742,6 +760,17 @@ export function TreeEditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import provenance */}
|
||||
{importMetadata && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2 text-xs font-label text-muted-foreground">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>
|
||||
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''}
|
||||
{importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Editor */}
|
||||
<TreeEditorLayout
|
||||
isMobile={isMobile}
|
||||
@@ -768,6 +797,15 @@ export function TreeEditorPage() {
|
||||
onClose={handleCloseFixModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && id && (
|
||||
<ExportFlowModal
|
||||
treeId={id}
|
||||
treeName={name || 'flow'}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
|
||||
import { X, RotateCcw, Play, Sparkles, FileUp } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
@@ -8,6 +8,8 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { ForkModal } from '@/components/library/ForkModal'
|
||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||
import { ImportFlowModal } from '@/components/library/ImportFlowModal'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
@@ -76,6 +78,10 @@ export function TreeLibraryPage() {
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
|
||||
// Import/Export modal state
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(null)
|
||||
|
||||
// AI builder state
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
@@ -250,6 +256,11 @@ export function TreeLibraryPage() {
|
||||
if (tree) setForkTarget(tree)
|
||||
}
|
||||
|
||||
const handleExportTree = (treeId: string) => {
|
||||
const tree = trees.find((t) => t.id === treeId)
|
||||
if (tree) setExportTarget(tree)
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
@@ -282,6 +293,13 @@ export function TreeLibraryPage() {
|
||||
Flow Assist
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
Import
|
||||
</button>
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
@@ -493,6 +511,7 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -509,6 +528,7 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -530,6 +550,7 @@ export function TreeLibraryPage() {
|
||||
)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -582,6 +603,20 @@ export function TreeLibraryPage() {
|
||||
onClose={() => setForkTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{exportTarget && (
|
||||
<ExportFlowModal
|
||||
treeId={exportTarget.id}
|
||||
treeName={exportTarget.name}
|
||||
onClose={() => setExportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && (
|
||||
<ImportFlowModal
|
||||
onClose={() => { setShowImportModal(false); loadTrees() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
34
frontend/src/types/flowTransfer.ts
Normal file
34
frontend/src/types/flowTransfer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface FlowExportCategory {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface FlowExportData {
|
||||
name: string
|
||||
description: string | null
|
||||
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
version: number
|
||||
author_name: string | null
|
||||
category: FlowExportCategory | null
|
||||
tags: string[]
|
||||
tree_structure: Record<string, unknown>
|
||||
intake_form: Record<string, unknown>[] | null
|
||||
}
|
||||
|
||||
export interface RFFlowFile {
|
||||
rfflow_version: string
|
||||
exported_at: string
|
||||
source_app: string
|
||||
format: 'json' | 'xml'
|
||||
flow: FlowExportData
|
||||
}
|
||||
|
||||
export interface FlowImportResponse {
|
||||
tree_id: string
|
||||
name: string
|
||||
tree_type: string
|
||||
status: string
|
||||
category_created: boolean
|
||||
tags_created: string[]
|
||||
validation_warnings: string[]
|
||||
}
|
||||
@@ -64,3 +64,10 @@ export type {
|
||||
AIChatGenerateResponse,
|
||||
AIChatImportResponse,
|
||||
} from './ai-chat'
|
||||
|
||||
export type {
|
||||
RFFlowFile,
|
||||
FlowExportData,
|
||||
FlowExportCategory,
|
||||
FlowImportResponse,
|
||||
} from './flowTransfer'
|
||||
|
||||
@@ -176,6 +176,12 @@ export interface Tree {
|
||||
updated_at: string
|
||||
usage_count: number
|
||||
fork_info: ForkInfo | null
|
||||
import_metadata: {
|
||||
original_author_name?: string | null
|
||||
exported_at?: string
|
||||
imported_at?: string
|
||||
source_app?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface TreeListItem {
|
||||
|
||||
Reference in New Issue
Block a user