diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx new file mode 100644 index 00000000..64797a02 --- /dev/null +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -0,0 +1,264 @@ +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 { 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 { + const { 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 panelRef = useRef(null) + + // Initialize/reset draft when nodeId changes + useEffect(() => { + if (node) { + setDraft(cloneWithoutChildren(node)) + setIsDirty(false) + setShowDeleteConfirm(false) + } + }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isDirty]) // 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 + const { children, ...draftWithoutChildren } = draft + updateNode(nodeId, draftWithoutChildren) + + // Auto-create answer stubs for new decision options without next_node_id + if (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 }) + } + } + + setIsDirty(false) + }, [draft, node, nodeId, updateNode, addNode]) + + const handleClose = useCallback(() => { + if (isDirty) { + if (!window.confirm('You have unsaved changes. Discard them?')) return + } + onClose() + }, [isDirty, onClose]) + + 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 ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+ ) +} + +// 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) +}