diff --git a/frontend/src/components/tree-editor/TreeCanvasNode.tsx b/frontend/src/components/tree-editor/TreeCanvasNode.tsx new file mode 100644 index 00000000..0dc99d6e --- /dev/null +++ b/frontend/src/components/tree-editor/TreeCanvasNode.tsx @@ -0,0 +1,365 @@ +import { useState, useCallback } from 'react' +import { + HelpCircle, + Zap, + CheckCircle, + Play, + Check, + X, + Copy, + Trash2, + GripVertical, + AlertCircle, + AlertTriangle, + ChevronDown, + ChevronRight, +} from 'lucide-react' +import { useTreeEditorStore } from '@/store/treeEditorStore' +import { NodeFormDecision } from './NodeFormDecision' +import { NodeFormAction } from './NodeFormAction' +import { NodeFormResolution } from './NodeFormResolution' +import type { TreeStructure } from '@/types' +import { cn } from '@/lib/utils' + +interface TreeCanvasNodeProps { + node: TreeStructure + depth: number + fromOption?: string + isExpanded: boolean + isNew: boolean + onToggleExpand: () => void + onSave: (nodeId: string, updates: Partial) => void + onCancelNew: (nodeId: string) => void + onDelete: (nodeId: string) => void + onDuplicate: (nodeId: string) => void + onDragStart: (e: React.DragEvent, nodeId: string) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void +} + +/** Clone a node without its children (for local draft state) */ +function cloneNodeWithoutChildren(node: TreeStructure): TreeStructure { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = node + return structuredClone(rest) as TreeStructure +} + +const NODE_TYPE_CONFIG = { + decision: { + icon: HelpCircle, + label: 'Decision', + borderClass: 'border-l-4 border-l-blue-500', + badgeClass: 'bg-blue-500/20 text-blue-400', + }, + action: { + icon: Zap, + label: 'Action', + borderClass: 'border-l-4 border-l-yellow-500', + badgeClass: 'bg-yellow-500/20 text-yellow-400', + }, + solution: { + icon: CheckCircle, + label: 'Solution', + borderClass: 'border-l-4 border-l-green-500', + badgeClass: 'bg-green-500/20 text-green-400', + }, +} as const + +export function TreeCanvasNode({ + node, + fromOption, + isExpanded, + isNew, + onToggleExpand, + onSave, + onCancelNew, + onDelete, + onDuplicate, + onDragStart, + onDragOver, + onDrop, +}: TreeCanvasNodeProps) { + const { validationErrors, selectedNodeId, selectNode } = useTreeEditorStore() + const isRoot = node.id === 'root' + const isSelected = selectedNodeId === node.id + + const nodeErrors = validationErrors.filter( + (e) => e.nodeId === node.id && e.severity === 'error' + ) + const nodeWarnings = validationErrors.filter( + (e) => e.nodeId === node.id && e.severity === 'warning' + ) + const hasError = nodeErrors.length > 0 + const hasWarning = nodeWarnings.length > 0 + + // Local draft state for inline editing + const [draft, setDraft] = useState(() => + cloneNodeWithoutChildren(node) + ) + + // Reset draft if node changes while editing (e.g. store update from undo) + const [lastNodeId, setLastNodeId] = useState(node.id) + if (node.id !== lastNodeId) { + setDraft(cloneNodeWithoutChildren(node)) + setLastNodeId(node.id) + } + + const handleDraftUpdate = useCallback((updates: Partial) => { + setDraft((prev) => ({ ...prev, ...updates })) + }, []) + + const handleSave = (e: React.MouseEvent) => { + e.stopPropagation() + // Strip children from draft before passing to onSave + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...draftWithoutChildren } = draft + onSave(node.id, draftWithoutChildren) + } + + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation() + if (isNew) { + onCancelNew(node.id) + } else { + // Discard draft changes and collapse + setDraft(cloneNodeWithoutChildren(node)) + onToggleExpand() + } + } + + const handleCardClick = () => { + selectNode(node.id) + onToggleExpand() + } + + const config = NODE_TYPE_CONFIG[node.type] + const TypeIcon = config.icon + + const getTitle = () => { + if (node.type === 'decision') return node.question || 'Untitled Question' + return node.title || `Untitled ${node.type}` + } + + const getOptionsSummary = () => { + if (node.type !== 'decision' || !node.options?.length) return null + const count = node.options.length + return `${count} option${count !== 1 ? 's' : ''}` + } + + return ( +
+ {/* Card Header */} +
+ {/* Drag handle (hide for root) */} + {!isRoot && ( + { + e.stopPropagation() + onDragStart(e, node.id) + }} + > + + + )} + + {/* Node type badge */} + {isRoot ? ( + + + START + + ) : ( + + + {config.label} + + )} + + {/* From-option label */} + {fromOption && ( + + {fromOption} + + )} + + {/* Title text (compact mode) */} + {!isExpanded && ( + + {getTitle()} + + )} + + {/* Options count badge */} + {!isExpanded && getOptionsSummary() && ( + + {getOptionsSummary()} + + )} + + {/* Validation badges (compact mode) */} + {!isExpanded && hasError && ( + e.message).join('\n')} + > + + {nodeErrors.length} + + )} + {!isExpanded && !hasError && hasWarning && ( + e.message).join('\n')} + > + + {nodeWarnings.length} + + )} + + {/* Unsaved badge */} + {!isExpanded && isNew && ( + + Unsaved + + )} + + {/* Expand/collapse chevron */} + {!isExpanded ? ( + + ) : ( + + )} + + {/* Editing action buttons (expanded state) */} + {isExpanded && ( +
+ {/* New badge */} + {isNew && ( + + Unsaved + + )} + + {/* Duplicate (hide for root) */} + {!isRoot && ( + + )} + + {/* Delete (hide for root) */} + {!isRoot && ( + + )} + + {/* Cancel */} + + + {/* Save */} + +
+ )} +
+ + {/* Expanded editing area */} + {isExpanded && ( +
+ {/* Validation errors */} + {(hasError || hasWarning) && ( +
+ {nodeErrors.map((error, i) => ( +
+ {error.message} +
+ ))} + {!hasError && + nodeWarnings.map((warning, i) => ( +
+ {warning.message} +
+ ))} +
+ )} + + {/* Type-specific form — uses draft, not live node */} + {draft.type === 'decision' && ( + + )} + {draft.type === 'action' && ( + + )} + {draft.type === 'solution' && ( + + )} +
+ )} +
+ ) +} + +export default TreeCanvasNode