feat: Add tree editor validation UI (Workstream A complete)

Implements comprehensive validation feedback system for tree editor:

Task A.1 - Circular Reference Detection:
- Added detectCircularRefs() function in treeEditorStore
- Detects loops in both decision options and action next_node_id chains
- Prevents infinite navigation paths

Task A.2 - ValidationSummary Component:
- Created collapsible panel showing error/warning count
- Click error to select problematic node
- Color-coded: red for errors, yellow for warnings
- Icon indicators (AlertCircle, AlertTriangle)

Task A.3 - TreeEditorPage Integration:
- Added ValidationSummary component display
- Save button disabled when errors exist
- Warnings are informational only (don't block save)
- Added manual "Validate" button in toolbar
- Imported CheckCircle2 icon for validate button

Task A.4 - Visual Node Error Indicators:
- Added error/warning badges on problem nodes
- Tooltip on hover showing specific error messages
- Red ring for errors, yellow ring for warnings
- Shows count of errors/warnings per node

All tasks from implementation plan completed.
Build tested successfully.

Related: Issue #1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-03 19:01:27 -05:00
parent 4378ec4b20
commit f93c8d84df
4 changed files with 237 additions and 12 deletions

View File

@@ -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()}
</span>
{/* Error/Warning badge */}
{(hasError || hasWarning) && (
<span
title={getValidationTooltip()}
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs',
hasError
? 'bg-destructive/20 text-destructive'
: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-500'
)}
>
{hasError ? (
<AlertCircle className="h-3 w-3" />
) : (
<AlertTriangle className="h-3 w-3" />
)}
<span className="hidden sm:inline">
{hasError ? `${nodeErrors.length} error${nodeErrors.length !== 1 ? 's' : ''}` : `${nodeWarnings.length} warning${nodeWarnings.length !== 1 ? 's' : ''}`}
</span>
</span>
)}
{/* Node ID */}
<span
className="hidden text-xs text-muted-foreground sm:inline cursor-help"

View File

@@ -0,0 +1,119 @@
import { useState } from 'react'
import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { ValidationError } from '@/store/treeEditorStore'
interface ValidationSummaryProps {
errors: ValidationError[]
onSelectNode: (nodeId: string) => 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 (
<div
className={cn(
'rounded-lg border',
errorItems.length > 0
? 'border-destructive/50 bg-destructive/5'
: 'border-yellow-500/50 bg-yellow-50 dark:bg-yellow-900/10'
)}
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
'flex w-full items-center justify-between p-3 text-left transition-colors hover:bg-black/5 dark:hover:bg-white/5',
errorItems.length > 0 ? 'text-destructive' : 'text-yellow-700 dark:text-yellow-500'
)}
>
<div className="flex items-center gap-2">
{errorItems.length > 0 ? (
<AlertCircle className="h-5 w-5" />
) : (
<AlertTriangle className="h-5 w-5" />
)}
<span className="font-medium">
{errorItems.length > 0 && (
<>
{errorItems.length} {errorItems.length === 1 ? 'Error' : 'Errors'}
</>
)}
{errorItems.length > 0 && warningItems.length > 0 && ', '}
{warningItems.length > 0 && (
<>
{warningItems.length} {warningItems.length === 1 ? 'Warning' : 'Warnings'}
</>
)}
</span>
</div>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{/* Error/Warning List */}
{isExpanded && (
<div className="space-y-1 border-t border-current/10 p-3">
{/* Errors */}
{errorItems.map((error, index) => (
<button
key={`error-${index}`}
onClick={() => handleErrorClick(error)}
className={cn(
'flex w-full items-start gap-2 rounded p-2 text-left text-sm transition-colors',
error.nodeId
? 'cursor-pointer hover:bg-destructive/10'
: 'cursor-default'
)}
>
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-destructive" />
<div className="flex-1">
<p className="text-destructive">{error.message}</p>
{error.nodeId && (
<p className="mt-0.5 text-xs text-muted-foreground">
Click to select node: {error.nodeId}
</p>
)}
</div>
</button>
))}
{/* Warnings */}
{warningItems.map((warning, index) => (
<button
key={`warning-${index}`}
onClick={() => handleErrorClick(warning)}
className={cn(
'flex w-full items-start gap-2 rounded p-2 text-left text-sm transition-colors',
warning.nodeId
? 'cursor-pointer hover:bg-yellow-100 dark:hover:bg-yellow-900/20'
: 'cursor-default'
)}
>
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-600 dark:text-yellow-500" />
<div className="flex-1">
<p className="text-yellow-700 dark:text-yellow-500">{warning.message}</p>
{warning.nodeId && (
<p className="mt-0.5 text-xs text-muted-foreground">
Click to select node: {warning.nodeId}
</p>
)}
</div>
</button>
))}
</div>
)}
</div>
)
}