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

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

View File

@@ -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"]

View 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
},
}

View File

@@ -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'

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

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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View 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}`)
}
}

View File

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

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

View 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[]
}

View File

@@ -64,3 +64,10 @@ export type {
AIChatGenerateResponse,
AIChatImportResponse,
} from './ai-chat'
export type {
RFFlowFile,
FlowExportData,
FlowExportCategory,
FlowImportResponse,
} from './flowTransfer'

View File

@@ -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 {