|
|
|
@@ -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
|
|
|
|
|