ralph/collaboration-and-character-variables #5
2
prd.json
2
prd.json
|
|
@ -638,7 +638,7 @@
|
||||||
"Verify in browser using dev-browser skill"
|
"Verify in browser using dev-browser skill"
|
||||||
],
|
],
|
||||||
"priority": 36,
|
"priority": 36,
|
||||||
"passes": false,
|
"passes": true,
|
||||||
"notes": ""
|
"notes": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
14
progress.txt
14
progress.txt
|
|
@ -520,3 +520,17 @@
|
||||||
- Remember to cleanup: remove the anchor from DOM and revoke the object URL
|
- 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
|
- 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)
|
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
|
// Inner component that uses useReactFlow hook
|
||||||
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
function FlowchartEditorInner({ projectId, projectName, initialData }: FlowchartEditorProps) {
|
||||||
// Define custom node types - memoized to prevent re-renders
|
// 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 [conditionEditor, setConditionEditor] = useState<ConditionEditorState>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
|
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)
|
// Check for saved draft on initial render (lazy initialization)
|
||||||
const [draftState, setDraftState] = useState<{
|
const [draftState, setDraftState] = useState<{
|
||||||
|
|
@ -379,8 +394,90 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}, [nodes, edges, projectName])
|
}, [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(() => {
|
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)
|
// Handle edge deletion via keyboard (Delete/Backspace)
|
||||||
|
|
@ -651,6 +748,44 @@ function FlowchartEditorInner({ projectId, projectName, initialData }: Flowchart
|
||||||
</div>
|
</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 notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue