From 39677a384137a1cad8e9207a1be6c2dc97157f4f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Fri, 6 Mar 2026 00:18:10 -0500 Subject: [PATCH] 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 --- backend/app/api/endpoints/tree_transfer.py | 2 +- backend/tests/test_tree_transfer.py | 364 ++++++++++++++++++ frontend/src/api/flowTransfer.ts | 18 + frontend/src/api/index.ts | 1 + .../components/library/ExportFlowModal.tsx | 132 +++++++ .../components/library/ImportFlowModal.tsx | 257 +++++++++++++ .../src/components/library/TreeGridView.tsx | 18 +- .../src/components/library/TreeListView.tsx | 18 +- .../src/components/library/TreeTableView.tsx | 18 +- frontend/src/lib/rfflowParser.ts | 153 ++++++++ frontend/src/pages/TreeEditorPage.tsx | 40 +- frontend/src/pages/TreeLibraryPage.tsx | 37 +- frontend/src/types/flowTransfer.ts | 34 ++ frontend/src/types/index.ts | 7 + frontend/src/types/tree.ts | 6 + 15 files changed, 1099 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_tree_transfer.py create mode 100644 frontend/src/api/flowTransfer.ts create mode 100644 frontend/src/components/library/ExportFlowModal.tsx create mode 100644 frontend/src/components/library/ImportFlowModal.tsx create mode 100644 frontend/src/lib/rfflowParser.ts create mode 100644 frontend/src/types/flowTransfer.ts diff --git a/backend/app/api/endpoints/tree_transfer.py b/backend/app/api/endpoints/tree_transfer.py index 4d335173..38a4123c 100644 --- a/backend/app/api/endpoints/tree_transfer.py +++ b/backend/app/api/endpoints/tree_transfer.py @@ -89,7 +89,7 @@ async def export_tree( tree_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], - format: str = Query("json", regex="^(json|xml)$"), + format: str = Query("json", pattern="^(json|xml)$"), ): """Export a tree as a downloadable .rfflow file (JSON or XML).""" # Load tree with relationships + author name diff --git a/backend/tests/test_tree_transfer.py b/backend/tests/test_tree_transfer.py new file mode 100644 index 00000000..d211e9f9 --- /dev/null +++ b/backend/tests/test_tree_transfer.py @@ -0,0 +1,364 @@ +"""Tests for flow export/import (.rfflow) endpoints.""" +import json +import xml.etree.ElementTree as ET +import pytest +from httpx import AsyncClient + + +# --- Helpers --- + +TREE_DATA = { + "name": "DNS Troubleshooting", + "description": "Diagnose DNS resolution issues", + "category": "Networking", + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Is DNS resolving?", + "options": [ + {"id": "yes", "label": "Yes", "next_node_id": "sol1"}, + {"id": "no", "label": "No", "next_node_id": "sol2"}, + ], + "children": [ + {"id": "sol1", "type": "solution", "title": "DNS OK", "description": "DNS is working", "solution": "No action needed"}, + {"id": "sol2", "type": "solution", "title": "DNS Fail", "description": "DNS is not resolving", "solution": "Check DNS server config"}, + ], + }, + "tags": ["dns", "networking"], +} + + +async def create_tree_with_tags(client: AsyncClient, headers: dict, data: dict | None = None) -> dict: + """Create a tree and return the response.""" + resp = await client.post("/api/v1/trees", json=data or TREE_DATA, headers=headers) + assert resp.status_code == 201 + return resp.json() + + +# --- Export Tests --- + +@pytest.mark.asyncio +async def test_export_json_format(client, auth_headers, test_tree): + """Export should return valid .rfflow JSON with correct structure.""" + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=json", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert "attachment" in resp.headers.get("content-disposition", "") + assert ".rfflow" in resp.headers.get("content-disposition", "") + + data = resp.json() + assert data["rfflow_version"] == "1.0" + assert data["source_app"] == "ResolutionFlow" + assert data["format"] == "json" + assert data["exported_at"] is not None + + flow = data["flow"] + assert flow["name"] == test_tree["name"] + assert flow["tree_structure"] is not None + assert flow["tree_type"] == "troubleshooting" + + # No IDs leaked + assert "id" not in flow or flow.get("id") is None + assert "author_id" not in flow + assert "account_id" not in flow + + +@pytest.mark.asyncio +async def test_export_xml_format(client, auth_headers, test_tree): + """Export as XML should be parseable and contain all required fields.""" + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=xml", + headers=auth_headers, + ) + assert resp.status_code == 200 + assert ".rfflow" in resp.headers.get("content-disposition", "") + + # Parse XML + root = ET.fromstring(resp.text) + assert root.tag == "rfflow" + assert root.get("version") == "1.0" + + flow_el = root.find("flow") + assert flow_el is not None + assert flow_el.find("name").text == test_tree["name"] + + # tree_structure should be valid JSON + ts_text = flow_el.find("tree_structure").text + ts = json.loads(ts_text) + assert ts["id"] == "root" + + +@pytest.mark.asyncio +async def test_export_with_category_and_tags(client, auth_headers): + """Export should include category and tag data.""" + tree = await create_tree_with_tags(client, auth_headers) + resp = await client.get( + f"/api/v1/trees/{tree['id']}/export?format=json", + headers=auth_headers, + ) + assert resp.status_code == 200 + flow = resp.json()["flow"] + assert len(flow["tags"]) == 2 + assert "dns" in flow["tags"] + assert "networking" in flow["tags"] + + +@pytest.mark.asyncio +async def test_export_access_control(client, auth_headers, test_admin, admin_auth_headers, test_tree): + """Users should only export trees they can access.""" + # Create a second user who can't access the tree + user2_data = { + "email": "other@example.com", + "password": "OtherPass123!", + "name": "Other User", + } + await client.post("/api/v1/auth/register", json=user2_data) + login_resp = await client.post("/api/v1/auth/login/json", json={ + "email": user2_data["email"], + "password": user2_data["password"], + }) + other_headers = {"Authorization": f"Bearer {login_resp.json()['access_token']}"} + + # The other user is in a different account, so can't see the tree + resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=json", + headers=other_headers, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_export_nonexistent_tree(client, auth_headers): + """Export of non-existent tree returns 404.""" + import uuid + resp = await client.get( + f"/api/v1/trees/{uuid.uuid4()}/export?format=json", + headers=auth_headers, + ) + assert resp.status_code == 404 + + +# --- Import Tests --- + +@pytest.mark.asyncio +async def test_import_happy_path(client, auth_headers, test_tree): + """Import should create a draft tree owned by the importing user.""" + # First export + export_resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=json", + headers=auth_headers, + ) + rfflow_data = export_resp.json() + + # Import + import_resp = await client.post( + "/api/v1/trees/import", + json=rfflow_data, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + result = import_resp.json() + assert result["status"] == "draft" + assert result["name"] == test_tree["name"] + assert result["tree_id"] is not None + + # Verify the created tree + tree_resp = await client.get( + f"/api/v1/trees/{result['tree_id']}", + headers=auth_headers, + ) + assert tree_resp.status_code == 200 + tree = tree_resp.json() + assert tree["status"] == "draft" + assert tree["import_metadata"] is not None + assert tree["import_metadata"]["source_app"] == "ResolutionFlow" + + +@pytest.mark.asyncio +async def test_import_with_name_override(client, auth_headers, test_tree): + """Import with name_override should use the override name.""" + export_resp = await client.get( + f"/api/v1/trees/{test_tree['id']}/export?format=json", + headers=auth_headers, + ) + rfflow_data = export_resp.json() + + import_resp = await client.post( + "/api/v1/trees/import?name_override=Custom%20Name", + json=rfflow_data, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + assert import_resp.json()["name"] == "Custom Name" + + +@pytest.mark.asyncio +async def test_import_with_new_tags(client, auth_headers): + """Import with new tags should create them automatically.""" + rfflow = { + "rfflow_version": "1.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "format": "json", + "flow": { + "name": "Test Import Tags", + "description": "Testing tag creation", + "tree_type": "troubleshooting", + "version": 1, + "author_name": "Test Author", + "category": None, + "tags": ["brand-new-tag", "another-tag"], + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Q?", + "options": [{"id": "a", "label": "A", "next_node_id": "s1"}], + "children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}], + }, + "intake_form": None, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 201 + result = resp.json() + assert "brand-new-tag" in result["tags_created"] + assert "another-tag" in result["tags_created"] + + +@pytest.mark.asyncio +async def test_import_with_category_creation(client, auth_headers): + """Import with a new category should create it.""" + rfflow = { + "rfflow_version": "1.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "format": "json", + "flow": { + "name": "Import Category Test", + "description": None, + "tree_type": "troubleshooting", + "version": 1, + "author_name": None, + "category": {"name": "New Category", "slug": "new-category"}, + "tags": [], + "tree_structure": { + "id": "root", + "type": "decision", + "question": "Q?", + "options": [{"id": "a", "label": "A", "next_node_id": "s1"}], + "children": [{"id": "s1", "type": "solution", "title": "S", "description": "D", "solution": "S"}], + }, + "intake_form": None, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 201 + assert resp.json()["category_created"] is True + + +@pytest.mark.asyncio +async def test_import_invalid_version(client, auth_headers): + """Import with unsupported rfflow version should return 422.""" + rfflow = { + "rfflow_version": "99.0", + "exported_at": "2026-03-05T14:30:00+00:00", + "source_app": "ResolutionFlow", + "format": "json", + "flow": { + "name": "Bad Version", + "tree_type": "troubleshooting", + "version": 1, + "tags": [], + "tree_structure": {"id": "root", "type": "decision", "question": "Q?", "options": [], "children": []}, + }, + } + + resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_import_round_trip(client, auth_headers): + """Export then import should produce a tree with matching data.""" + original = await create_tree_with_tags(client, auth_headers) + + # Export + export_resp = await client.get( + f"/api/v1/trees/{original['id']}/export?format=json", + headers=auth_headers, + ) + rfflow = export_resp.json() + + # Import + import_resp = await client.post( + "/api/v1/trees/import", + json=rfflow, + headers=auth_headers, + ) + assert import_resp.status_code == 201 + result = import_resp.json() + + # Verify imported tree matches original structure + tree_resp = await client.get( + f"/api/v1/trees/{result['tree_id']}", + headers=auth_headers, + ) + imported_tree = tree_resp.json() + assert imported_tree["name"] == original["name"] + assert imported_tree["tree_structure"]["id"] == original["tree_structure"]["id"] + assert imported_tree["tree_type"] == original["tree_type"] + assert imported_tree["status"] == "draft" # Always draft on import + + +@pytest.mark.asyncio +async def test_import_xml_round_trip(client, auth_headers): + """Export as XML then re-import the parsed structure should work.""" + original = await create_tree_with_tags(client, auth_headers) + + # Export as XML + export_resp = await client.get( + f"/api/v1/trees/{original['id']}/export?format=xml", + headers=auth_headers, + ) + assert export_resp.status_code == 200 + + # Parse XML to build import request (simulating frontend parser) + root = ET.fromstring(export_resp.text) + flow_el = root.find("flow") + tags = [t.text for t in flow_el.find("tags").findall("tag")] + ts = json.loads(flow_el.find("tree_structure").text) + + cat_el = flow_el.find("category") + category = None + if cat_el is not None: + cat_name = cat_el.find("name") + cat_slug = cat_el.find("slug") + if cat_name is not None and cat_slug is not None: + category = {"name": cat_name.text, "slug": cat_slug.text} + + rfflow = { + "rfflow_version": root.get("version"), + "exported_at": root.find("exported_at").text, + "source_app": root.find("source_app").text, + "format": "xml", + "flow": { + "name": flow_el.find("name").text, + "description": flow_el.find("description").text or None, + "tree_type": flow_el.find("tree_type").text, + "version": int(flow_el.find("version").text), + "author_name": flow_el.find("author_name").text or None, + "category": category, + "tags": tags, + "tree_structure": ts, + "intake_form": None, + }, + } + + import_resp = await client.post("/api/v1/trees/import", json=rfflow, headers=auth_headers) + assert import_resp.status_code == 201 + result = import_resp.json() + assert result["name"] == original["name"] diff --git a/frontend/src/api/flowTransfer.ts b/frontend/src/api/flowTransfer.ts new file mode 100644 index 00000000..42e40c63 --- /dev/null +++ b/frontend/src/api/flowTransfer.ts @@ -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 { + const response = await apiClient.get(`/trees/${treeId}/export`, { + params: { format }, + responseType: 'blob', + }) + return response.data + }, + + async importFlow(data: RFFlowFile, nameOverride?: string): Promise { + const params = nameOverride ? { name_override: nameOverride } : undefined + const response = await apiClient.post('/trees/import', data, { params }) + return response.data + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1fbade11..420aafb0 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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' diff --git a/frontend/src/components/library/ExportFlowModal.tsx b/frontend/src/components/library/ExportFlowModal.tsx new file mode 100644 index 00000000..710d2acd --- /dev/null +++ b/frontend/src/components/library/ExportFlowModal.tsx @@ -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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

Export Flow

+
+ +
+ + {/* Body */} +
+

+ Export {treeName} as an .rfflow file. +

+ +
+ Format + {(['json', 'xml'] as const).map((fmt) => ( + + ))} +
+
+ + {/* Footer */} +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/library/ImportFlowModal.tsx b/frontend/src/components/library/ImportFlowModal.tsx new file mode 100644 index 00000000..da442e4f --- /dev/null +++ b/frontend/src/components/library/ImportFlowModal.tsx @@ -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 = { + troubleshooting: 'Troubleshooting', + procedural: 'Project', + maintenance: 'Maintenance', +} + +export function ImportFlowModal({ onClose }: ImportFlowModalProps) { + const navigate = useNavigate() + const fileInputRef = useRef(null) + const [step, setStep] = useState<'pick' | 'preview'>('pick') + const [parsed, setParsed] = useState(null) + const [nameOverride, setNameOverride] = useState('') + const [parseError, setParseError] = useState(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) => { + 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 ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ +

Import Flow

+
+ +
+ + {/* Body */} +
+ {step === 'pick' && ( +
+
fileInputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + +

+ Drop .rfflow file here or browse +

+

JSON or XML format

+
+ + {parseError && ( +
+ +

{parseError}

+
+ )} +
+ )} + + {step === 'preview' && parsed && ( +
+ {/* Editable name */} +
+ + 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' + )} + /> +
+ + {/* Flow info */} +
+
+ Type: + + {TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type} + +
+ + {parsed.flow.description && ( +
+ Description: +

{parsed.flow.description}

+
+ )} + + {parsed.flow.category && ( +
+ Category: + {parsed.flow.category.name} +
+ )} + + {parsed.flow.tags.length > 0 && ( +
+ Tags: + {parsed.flow.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {parsed.flow.author_name && ( +
+ Original author: + {parsed.flow.author_name} +
+ )} + +
+ Version: + v{parsed.flow.version} +
+
+ +

+ Flow will be imported as a draft. +

+
+ )} +
+ + {/* Footer */} +
+ {step === 'preview' && ( + + )} + + {step === 'preview' && ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx index dd1a3c1c..3fb34531 100644 --- a/frontend/src/components/library/TreeGridView.tsx +++ b/frontend/src/components/library/TreeGridView.tsx @@ -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 onTogglePin?: (treeId: string) => void pinLoadingTreeIds?: Set @@ -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
+ {onExportTree && ( + + )} {onForkTree && ( )} + {onExportTree && ( + + )} {onForkTree && ( + )} {onForkTree && ( + )} +
) } diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 6aa05e99..1cbc7014 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -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(null) + // Import/Export modal state + const [showImportModal, setShowImportModal] = useState(false) + const [exportTarget, setExportTarget] = useState(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 )} + 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 && ( + setExportTarget(null)} + /> + )} + + {showImportModal && ( + { setShowImportModal(false); loadTrees() }} + /> + )} ) } diff --git a/frontend/src/types/flowTransfer.ts b/frontend/src/types/flowTransfer.ts new file mode 100644 index 00000000..dd7e10db --- /dev/null +++ b/frontend/src/types/flowTransfer.ts @@ -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 + intake_form: Record[] | 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[] +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 6d2098a3..9a2cde9f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -64,3 +64,10 @@ export type { AIChatGenerateResponse, AIChatImportResponse, } from './ai-chat' + +export type { + RFFlowFile, + FlowExportData, + FlowExportCategory, + FlowImportResponse, +} from './flowTransfer' diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts index f9bd54ff..9294f6d9 100644 --- a/frontend/src/types/tree.ts +++ b/frontend/src/types/tree.ts @@ -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 {