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

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