diff --git a/frontend/src/components/tree-editor/NodeList.tsx b/frontend/src/components/tree-editor/NodeList.tsx index 726b62cc..0f924bbb 100644 --- a/frontend/src/components/tree-editor/NodeList.tsx +++ b/frontend/src/components/tree-editor/NodeList.tsx @@ -10,7 +10,9 @@ import { CheckCircle, ChevronDown, ChevronRight, - Play + Play, + AlertCircle, + AlertTriangle } from 'lucide-react' import { useTreeEditorStore } from '@/store/treeEditorStore' import { NodeEditorModal } from './NodeEditorModal' @@ -58,10 +60,24 @@ function NodeListItem({ const [isCollapsed, setIsCollapsed] = useState(false) const isSelected = selectedNodeId === node.id const isRootNode = node.id === 'root' - const hasError = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error') - const hasWarning = validationErrors.some(e => e.nodeId === node.id && e.severity === 'warning') + 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 const hasChildren = node.children && node.children.length > 0 + // Get error/warning messages for tooltip + const getValidationTooltip = () => { + const messages: string[] = [] + if (hasError) { + messages.push(...nodeErrors.map(e => `❌ ${e.message}`)) + } + if (hasWarning) { + messages.push(...nodeWarnings.map(e => `⚠️ ${e.message}`)) + } + return messages.join('\n') + } + const isDragTarget = dragOverTarget?.parentId === parentId && dragOverTarget?.index === index @@ -200,6 +216,28 @@ function NodeListItem({ {getNodeLabel()} + {/* Error/Warning badge */} + {(hasError || hasWarning) && ( + + {hasError ? ( + + ) : ( + + )} + + {hasError ? `${nodeErrors.length} error${nodeErrors.length !== 1 ? 's' : ''}` : `${nodeWarnings.length} warning${nodeWarnings.length !== 1 ? 's' : ''}`} + + + )} + {/* Node ID */} void +} + +export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryProps) { + const [isExpanded, setIsExpanded] = useState(true) + + const errorItems = errors.filter(e => e.severity === 'error') + const warningItems = errors.filter(e => e.severity === 'warning') + + if (errors.length === 0) return null + + const handleErrorClick = (error: ValidationError) => { + if (error.nodeId) { + onSelectNode(error.nodeId) + } + } + + return ( +
0 + ? 'border-destructive/50 bg-destructive/5' + : 'border-yellow-500/50 bg-yellow-50 dark:bg-yellow-900/10' + )} + > + {/* Header */} + + + {/* Error/Warning List */} + {isExpanded && ( +
+ {/* Errors */} + {errorItems.map((error, index) => ( + + ))} + + {/* Warnings */} + {warningItems.map((warning, index) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index 5bf947a2..84911061 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -1,11 +1,12 @@ import { useEffect, useState, useCallback } from 'react' import { useParams, useNavigate, useBlocker } from 'react-router-dom' import { useStore } from 'zustand' -import { Undo2, Redo2, Save } from 'lucide-react' +import { Undo2, Redo2, Save, CheckCircle2 } from 'lucide-react' import { treesApi } from '@/api' import type { TreeCreate, TreeUpdate } from '@/types' import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore' import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' +import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { cn } from '@/lib/utils' @@ -29,7 +30,8 @@ export function TreeEditorPage() { getTreeForSave, markSaved, setLoading, - setSaving + setSaving, + selectNode } = useTreeEditorStore() // Access undo/redo from temporal store @@ -38,6 +40,9 @@ export function TreeEditorPage() { const [showDraftPrompt, setShowDraftPrompt] = useState(false) const [saveError, setSaveError] = useState(null) + // Calculate if there are blocking errors + const hasBlockingErrors = validationErrors.some(e => e.severity === 'error') + // Block navigation if there are unsaved changes const blocker = useBlocker( ({ currentLocation, nextLocation }) => @@ -122,6 +127,14 @@ export function TreeEditorPage() { setShowDraftPrompt(false) } + const handleManualValidate = () => { + validate() + } + + const handleSelectNode = (nodeId: string) => { + selectNode(nodeId) + } + const handleSave = useCallback(async () => { setSaveError(null) @@ -307,13 +320,28 @@ export function TreeEditorPage() {
+ {/* Validate */} + + {/* Save */}
)} - {/* Validation Errors Summary */} - {validationErrors.filter(e => e.severity === 'error').length > 0 && ( -
- {validationErrors.filter(e => e.severity === 'error').length} validation error(s) found. - Please fix them before saving. + {/* Validation Summary */} + {validationErrors.length > 0 && ( +
+
)} diff --git a/frontend/src/store/treeEditorStore.ts b/frontend/src/store/treeEditorStore.ts index ff2a41b3..495e5091 100644 --- a/frontend/src/store/treeEditorStore.ts +++ b/frontend/src/store/treeEditorStore.ts @@ -623,6 +623,44 @@ export const useTreeEditorStore = create()( validateNode(state.treeStructure) + // Check for circular references in next_node_id chains + const detectCircularRefs = (startId: string, visited: Set = new Set()): boolean => { + if (visited.has(startId)) return true + visited.add(startId) + + const node = findNodeInTree(startId, state.treeStructure) + if (!node) return false + + // Check options + if (node.options) { + for (const opt of node.options) { + if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) { + errors.push({ + nodeId: node.id, + message: `Circular reference detected: "${opt.label}" creates a loop`, + severity: 'error' + }) + return true + } + } + } + + // Check next_node_id + if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) { + errors.push({ + nodeId: node.id, + message: `Circular reference detected in node "${node.title || node.id}"`, + severity: 'error' + }) + return true + } + + return false + } + + // Run from root + detectCircularRefs('root') + // Check for at least one solution if (!hasSolution) { errors.push({