feat: [US-036] - Import project from .vnflow file

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-22 18:23:02 -03:00
parent 78479f3234
commit ee0a6f9424
3 changed files with 151 additions and 2 deletions

View File

@ -638,7 +638,7 @@
"Verify in browser using dev-browser skill"
],
"priority": 36,
"passes": false,
"passes": true,
"notes": ""
},
{

View File

@ -520,3 +520,17 @@
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
- Props needed for export: pass data down from server components (e.g., projectName) to client components that need them
---
## 2026-01-22 - US-036
- What was implemented: Import project from .vnflow file functionality
- Files changed:
- src/app/editor/[projectId]/FlowchartEditor.tsx - implemented handleImport, handleFileSelect, validation, and confirmation dialog for unsaved changes
- **Learnings for future iterations:**
- Use hidden `<input type="file">` element with ref to trigger file picker programmatically via `ref.current?.click()`
- Accept multiple file extensions using comma-separated values in `accept` attribute: `accept=".vnflow,.json"`
- Reset file input value after selection (`event.target.value = ''`) to allow re-selecting the same file
- Use FileReader API with `readAsText()` for reading file contents, handle onload and onerror callbacks
- Type guard function `isValidFlowchartData()` validates imported JSON structure before loading
- Track unsaved changes by comparing current state to initialData using JSON.stringify comparison
- Show confirmation dialog before import if there are unsaved changes to prevent accidental data loss
---

View File

@ -145,6 +145,15 @@ function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
// Validate imported flowchart data structure
function isValidFlowchartData(data: unknown): data is FlowchartData {
if (!data || typeof data !== 'object') return false
const obj = data as Record<string, unknown>
if (!Array.isArray(obj.nodes)) return false
if (!Array.isArray(obj.edges)) return false
return true
}
// Inner component that uses useReactFlow hook
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
// Define custom node types - memoized to prevent re-renders
@ -171,6 +180,12 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
const [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
const [isSaving, setIsSaving] = useState(false)
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const [importConfirmDialog, setImportConfirmDialog] = useState<{
pendingData: FlowchartData
} | null>(null)
// Ref for hidden file input
const fileInputRef = useRef<HTMLInputElement>(null)
// Check for saved draft on initial render (lazy initialization)
const [draftState, setDraftState] = useState<{
@ -379,8 +394,90 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
URL.revokeObjectURL(url)
}, [nodes, edges, projectName])
// Check if current flowchart has unsaved changes
const hasUnsavedChanges = useCallback(() => {
const currentData: FlowchartData = {
nodes: fromReactFlowNodes(nodes),
edges: fromReactFlowEdges(edges),
}
return !flowchartDataEquals(currentData, initialData)
}, [nodes, edges, initialData])
// Load imported data into React Flow
const loadImportedData = useCallback(
(data: FlowchartData) => {
setNodes(toReactFlowNodes(data.nodes))
setEdges(toReactFlowEdges(data.edges))
setToast({ message: 'Project imported successfully', type: 'success' })
},
[setNodes, setEdges]
)
// Handle file selection from file picker
const handleFileSelect = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Reset file input so same file can be selected again
event.target.value = ''
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const parsedData = JSON.parse(content)
// Validate the imported data
if (!isValidFlowchartData(parsedData)) {
setToast({
message: 'Invalid file format. File must contain nodes and edges arrays.',
type: 'error',
})
return
}
// Check if current project has unsaved changes
if (hasUnsavedChanges()) {
// Show confirmation dialog
setImportConfirmDialog({ pendingData: parsedData })
} else {
// Load data directly
loadImportedData(parsedData)
}
} catch {
setToast({
message: 'Failed to parse file. Please ensure it is valid JSON.',
type: 'error',
})
}
}
reader.onerror = () => {
setToast({ message: 'Failed to read file.', type: 'error' })
}
reader.readAsText(file)
},
[hasUnsavedChanges, loadImportedData]
)
// Handle import button click - opens file picker
const handleImport = useCallback(() => {
// TODO: Implement in US-036
fileInputRef.current?.click()
}, [])
// Confirm import (discard unsaved changes)
const handleConfirmImport = useCallback(() => {
if (importConfirmDialog?.pendingData) {
loadImportedData(importConfirmDialog.pendingData)
}
setImportConfirmDialog(null)
}, [importConfirmDialog, loadImportedData])
// Cancel import
const handleCancelImport = useCallback(() => {
setImportConfirmDialog(null)
}, [])
// Handle edge deletion via keyboard (Delete/Backspace)
@ -651,6 +748,44 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
</div>
)}
{/* Import confirmation dialog */}
{importConfirmDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-900">
<h2 className="mb-2 text-lg font-semibold text-zinc-900 dark:text-white">
Unsaved Changes
</h2>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">
You have unsaved changes. Importing a new file will discard your
current work. Are you sure you want to continue?
</p>
<div className="flex gap-3">
<button
onClick={handleConfirmImport}
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
>
Discard &amp; Import
</button>
<button
onClick={handleCancelImport}
className="flex-1 rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Hidden file input for import */}
<input
ref={fileInputRef}
type="file"
accept=".vnflow,.json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Toast notification */}
{toast && (
<Toast