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:
chihlasm
2026-03-06 00:18:10 -05:00
parent ee9895de5d
commit 39677a3841
15 changed files with 1099 additions and 6 deletions

View File

@@ -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>
)
}