ralph/vn-flowchart-editor #4
2
prd.json
2
prd.json
|
|
@ -638,7 +638,7 @@
|
|||
"Verify in browser using dev-browser skill"
|
||||
],
|
||||
"priority": 36,
|
||||
"passes": false,
|
||||
"passes": true,
|
||||
"notes": ""
|
||||
},
|
||||
{
|
||||
|
|
|
|||
14
progress.txt
14
progress.txt
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue