import { useState, useCallback, useEffect, useRef } from 'react' import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react' import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' import { NodeFormDecision } from './NodeFormDecision' import { NodeFormAction } from './NodeFormAction' import { NodeFormResolution } from './NodeFormResolution' import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { cn } from '@/lib/utils' import type { TreeStructure, NodeType } from '@/types' interface NodeEditorPanelProps { nodeId: string onClose: () => void onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void } const TYPE_CONFIG: Record, { icon: typeof HelpCircle; label: string; badgeClass: string }> = { decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' }, action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' }, solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' }, } function cloneWithoutChildren(node: TreeStructure): TreeStructure { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { children: _children, ...rest } = node return structuredClone(rest) as TreeStructure } export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) { const treeStructure = useTreeEditorStore(s => s.treeStructure) const updateNode = useTreeEditorStore(s => s.updateNode) const deleteNode = useTreeEditorStore(s => s.deleteNode) const duplicateNode = useTreeEditorStore(s => s.duplicateNode) const addNode = useTreeEditorStore(s => s.addNode) const selectNode = useTreeEditorStore(s => s.selectNode) const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null const [draft, setDraft] = useState(null) const [isDirty, setIsDirty] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) const panelRef = useRef(null) // Initialize/reset draft when nodeId changes or when node type changes // (e.g., answer stub → decision/action/solution via type picker) useEffect(() => { if (node) { // eslint-disable-next-line react-hooks/set-state-in-effect setDraft(cloneWithoutChildren(node)) setIsDirty(false) setShowDeleteConfirm(false) } }, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps const handleDraftUpdate = useCallback((updates: Partial) => { setDraft(prev => prev ? { ...prev, ...updates } : prev) setIsDirty(true) }, []) const handleSave = useCallback(() => { if (!draft || !node) return // eslint-disable-next-line @typescript-eslint/no-unused-vars const { children: _children, ...draftWithoutChildren } = draft updateNode(nodeId, draftWithoutChildren) // Auto-create answer stubs for new decision options without next_node_id if (draft.type === 'decision' && draft.options) { const options = draft.options.filter(o => o.label.trim()) const stubsCreated: Array<{ optId: string; stubId: string }> = [] options.forEach(opt => { if (!opt.next_node_id) { const stubId = addNode(nodeId, 'answer') updateNode(stubId, { title: opt.label }) stubsCreated.push({ optId: opt.id, stubId }) } }) if (stubsCreated.length > 0) { const updatedOptions = options.map(o => { const stub = stubsCreated.find(s => s.optId === o.id) return stub ? { ...o, next_node_id: stub.stubId } : o }) updateNode(nodeId, { options: updatedOptions }) } } // Auto-create answer stub for action node without next_node_id if (draft.type === 'action' && !draft.next_node_id) { const stubId = addNode(nodeId, 'answer') updateNode(stubId, { title: 'Next Step' }) updateNode(nodeId, { next_node_id: stubId }) } setIsDirty(false) }, [draft, node, nodeId, updateNode, addNode]) const handleClose = useCallback(() => { if (isDirty) { setShowDiscardConfirm(true) return } onClose() }, [isDirty, onClose]) // Escape to close useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { handleClose() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [handleClose]) const handleDelete = useCallback(() => { if (!treeStructure) return clearInboundReferences(nodeId, treeStructure, updateNode) deleteNode(nodeId) onClose() }, [nodeId, treeStructure, updateNode, deleteNode, onClose]) const handleDuplicate = useCallback(() => { const newId = duplicateNode(nodeId) if (newId) { selectNode(newId) } }, [nodeId, duplicateNode, selectNode]) if (!node || !draft) return null // Answer stub: show type picker instead of form if (node.type === 'answer') { return (
{node.title || 'Answer Placeholder'}

Choose a type for this node:

{(['decision', 'action', 'solution'] as const).map(type => { const cfg = TYPE_CONFIG[type] const TypeIcon = cfg.icon return ( ) })}
) } const config = TYPE_CONFIG[node.type as Exclude] ?? TYPE_CONFIG.decision const TypeIcon = config.icon const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`) const isRoot = treeStructure?.id === nodeId return (
{/* Header */}
{title}
{/* Body — scrollable form area */}
{draft.type === 'decision' && } {draft.type === 'action' && } {draft.type === 'solution' && }
{/* Footer */}
{!isRoot && ( <> {showDeleteConfirm ? (
) : ( )} )}
setShowDiscardConfirm(false)} onConfirm={() => { setShowDiscardConfirm(false) onClose() }} title="Discard Changes" message="You have unsaved changes. Discard them?" confirmLabel="Discard" />
) } // Clear all next_node_id references to a node before deleting function clearInboundReferences( nodeId: string, treeStructure: TreeStructure, updateNode: (id: string, updates: Partial) => void ) { function walk(node: TreeStructure) { if (node.type === 'decision' && node.options) { const needsUpdate = node.options.some(o => o.next_node_id === nodeId) if (needsUpdate) { updateNode(node.id, { options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o), }) } } if (node.type === 'action' && node.next_node_id === nodeId) { updateNode(node.id, { next_node_id: '' }) } node.children?.forEach(walk) } walk(treeStructure) }