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:
@@ -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
|
||||
|
||||
364
backend/tests/test_tree_transfer.py
Normal file
364
backend/tests/test_tree_transfer.py
Normal 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"]
|
||||
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