diff --git a/prd.json b/prd.json index fb3e809..ffe449f 100644 --- a/prd.json +++ b/prd.json @@ -638,7 +638,7 @@ "Verify in browser using dev-browser skill" ], "priority": 36, - "passes": false, + "passes": true, "notes": "" }, { diff --git a/progress.txt b/progress.txt index a260d39..5f21113 100644 --- a/progress.txt +++ b/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 `` 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 +--- diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 6ded190..0c5b024 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -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 + 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(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(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) => { + 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 )} + {/* Import confirmation dialog */} + {importConfirmDialog && ( +
+
+

+ Unsaved Changes +

+

+ You have unsaved changes. Importing a new file will discard your + current work. Are you sure you want to continue? +

+
+ + +
+
+
+ )} + + {/* Hidden file input for import */} + + {/* Toast notification */} {toast && (