Problem: Child nodes created via NodePicker while editing a parent node would disappear when clicking "Done" on the modal. This caused: - Child nodes not appearing in tree after closing modal - Validation errors about non-existent nodes - Tree unable to save Root cause: Modal used structuredClone() to create local draft state, which included a stale `children: []` array. When saving, this overwrote the actual children that were added to the store via addNode(). Fix: Exclude `children` from the draft when saving, since children are managed separately by addNode/deleteNode store actions. Also documented this critical pattern in LESSONS-LEARNED.md for future reference when implementing modals with local draft state. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
122 lines
3.9 KiB
TypeScript
122 lines
3.9 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react'
|
|
import { Modal } from '@/components/common/Modal'
|
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
|
import { NodeFormDecision } from './NodeFormDecision'
|
|
import { NodeFormAction } from './NodeFormAction'
|
|
import { NodeFormResolution } from './NodeFormResolution'
|
|
import type { TreeStructure } from '@/types'
|
|
|
|
interface NodeEditorModalProps {
|
|
node: TreeStructure
|
|
onClose: () => void
|
|
/** If true, this is a brand new node - cancel will delete it entirely */
|
|
isNewNode?: boolean
|
|
}
|
|
|
|
export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditorModalProps) {
|
|
const { updateNode, deleteNode, validationErrors } = useTreeEditorStore()
|
|
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id)
|
|
|
|
// Local draft state - changes are NOT persisted until "Done" is clicked
|
|
const [draft, setDraft] = useState<TreeStructure>(() => structuredClone(node))
|
|
|
|
// Reset draft when node changes (e.g., external update)
|
|
useEffect(() => {
|
|
setDraft(structuredClone(node))
|
|
}, [node.id]) // Only reset when switching to a different node
|
|
|
|
const handleUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
|
setDraft(prev => ({ ...prev, ...updates }))
|
|
}, [])
|
|
|
|
const handleSave = () => {
|
|
// Commit all draft changes to the store
|
|
// IMPORTANT: Exclude 'children' from the update - children are managed separately
|
|
// by addNode/deleteNode and we don't want to overwrite them with stale draft data
|
|
const { children, ...draftWithoutChildren } = draft
|
|
updateNode(node.id, draftWithoutChildren)
|
|
onClose()
|
|
}
|
|
|
|
const handleCancel = () => {
|
|
if (isNewNode) {
|
|
// Delete the unsaved new node entirely
|
|
deleteNode(node.id)
|
|
}
|
|
// Discard changes and close (draft is just thrown away)
|
|
onClose()
|
|
}
|
|
|
|
const getTitle = () => {
|
|
switch (node.type) {
|
|
case 'decision':
|
|
return 'Edit Decision Node'
|
|
case 'action':
|
|
return 'Edit Action Node'
|
|
case 'solution':
|
|
return 'Edit Solution Node'
|
|
default:
|
|
return 'Edit Node'
|
|
}
|
|
}
|
|
|
|
const footerContent = (
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleCancel}
|
|
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground hover:bg-accent"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
|
|
{/* Node ID display */}
|
|
<div className="mb-4 text-xs text-muted-foreground">
|
|
Node ID: <code className="rounded bg-muted px-1 py-0.5">{node.id}</code>
|
|
</div>
|
|
|
|
{/* Validation errors */}
|
|
{nodeErrors.length > 0 && (
|
|
<div className="mb-4 space-y-1">
|
|
{nodeErrors.map((error, i) => (
|
|
<div
|
|
key={i}
|
|
className={`rounded-md px-3 py-2 text-sm ${
|
|
error.severity === 'error'
|
|
? 'bg-destructive/10 text-destructive'
|
|
: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'
|
|
}`}
|
|
>
|
|
{error.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Type-specific form - uses draft state, not the original node */}
|
|
{draft.type === 'decision' && (
|
|
<NodeFormDecision node={draft} onUpdate={handleUpdate} />
|
|
)}
|
|
{draft.type === 'action' && (
|
|
<NodeFormAction node={draft} onUpdate={handleUpdate} />
|
|
)}
|
|
{draft.type === 'solution' && (
|
|
<NodeFormResolution node={draft} onUpdate={handleUpdate} />
|
|
)}
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default NodeEditorModal
|