Files
resolutionflow/frontend/src/components/tree-editor/NodeEditorModal.tsx
chihlasm 40373a835c Fix modal draft state overwriting store-managed children
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>
2026-01-30 01:47:00 -05:00

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