feat: Validierungs-Panel mit Details und Click-to-Jump
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

Issue #1 + #3: Validierung zeigt keine Details / Fehler-Lokalisierung

NEU: ValidationPanel Component
- Zeigt alle Fehler und Warnungen mit vollständigen Details
- Gruppiert nach Severity (Fehler/Warnungen)
- Aufklappbar pro Gruppe (collapsible)
- Click-to-Jump: Fehler mit nodeId sind klickbar
- Klick selektiert betroffene Node → Config-Panel öffnet sich
- Schließbar, öffnet sich automatisch bei neuen Fehler/Warnungen

Features:
- Fixed position (bottom-right, über Canvas)
- Farb-Kodierung: Rot (Errors) / Gelb (Warnings)
- Hover-Effekt auf klickbare Items
- Type-Labels (structure, isolation, etc.)
- Scrollbar bei vielen Fehler/Warnungen
- X-Button zum Schließen

UX-Verbesserungen:
- Kein Raten mehr: Zeigt WAS der Fehler ist
- Kein Suchen mehr: Klick springt direkt zur Node
- Übersichtlich auch bei vielen Fehler/Warnungen
- Funktioniert in großen Workflows

Technisch:
- handleValidationNodeClick: setSelectedNodeId
- Auto-show bei errors.length > 0 || warnings.length > 0
- State: showValidationPanel (closable)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-11 15:27:14 +02:00
parent 3fa01dd686
commit 549c31431e
2 changed files with 272 additions and 0 deletions

View File

@ -0,0 +1,246 @@
import { useState } from 'react'
import { AlertCircle, AlertTriangle, ChevronDown, ChevronRight, X } from 'lucide-react'
/**
* ValidationPanel - Zeigt Fehler und Warnungen mit Details
*
* Features:
* - Aufklappbar (collapsible)
* - Click-to-Jump zu betroffener Node
* - Gruppierung nach Severity
* - Klare visuelle Trennung
*/
export function ValidationPanel({ errors, warnings, onNodeClick, onClose }) {
const [isExpanded, setIsExpanded] = useState(true)
const [showErrors, setShowErrors] = useState(true)
const [showWarnings, setShowWarnings] = useState(true)
const totalCount = errors.length + warnings.length
if (totalCount === 0) return null
const handleItemClick = (item) => {
if (item.nodeId && onNodeClick) {
onNodeClick(item.nodeId)
}
}
return (
<div style={{
position: 'fixed',
bottom: 90,
right: 20,
width: 400,
maxHeight: '60vh',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
zIndex: 100
}}>
{/* Header */}
<div
onClick={() => setIsExpanded(!isExpanded)}
style={{
padding: '12px 16px',
borderBottom: isExpanded ? '1px solid var(--border)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
userSelect: 'none',
background: errors.length > 0 ? 'rgba(216, 90, 48, 0.1)' : 'rgba(255, 193, 7, 0.1)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
<AlertCircle size={18} color={errors.length > 0 ? 'var(--danger)' : '#f59e0b'} />
<span style={{ fontWeight: 600, fontSize: 14 }}>
Validierung
</span>
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
{errors.length > 0 && `${errors.length} Fehler`}
{errors.length > 0 && warnings.length > 0 && ', '}
{warnings.length > 0 && `${warnings.length} Warnung${warnings.length > 1 ? 'en' : ''}`}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation()
onClose && onClose()
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 4,
display: 'flex',
alignItems: 'center'
}}
>
<X size={16} color="var(--text3)" />
</button>
</div>
{/* Content */}
{isExpanded && (
<div style={{
overflowY: 'auto',
maxHeight: 'calc(60vh - 60px)'
}}>
{/* Errors */}
{errors.length > 0 && (
<div>
<div
onClick={() => setShowErrors(!showErrors)}
style={{
padding: '8px 16px',
background: 'rgba(216, 90, 48, 0.05)',
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
userSelect: 'none',
borderBottom: '1px solid var(--border)'
}}
>
{showErrors ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<AlertCircle size={16} color="var(--danger)" />
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--danger)' }}>
Fehler ({errors.length})
</span>
</div>
{showErrors && (
<div>
{errors.map((error, idx) => (
<div
key={idx}
onClick={() => handleItemClick(error)}
style={{
padding: '10px 16px',
borderBottom: idx < errors.length - 1 ? '1px solid var(--border)' : 'none',
cursor: error.nodeId ? 'pointer' : 'default',
background: error.nodeId ? 'transparent' : 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (error.nodeId) e.currentTarget.style.background = 'rgba(216, 90, 48, 0.05)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
<div style={{
fontSize: 13,
color: 'var(--text1)',
marginBottom: 4
}}>
{error.message}
</div>
{error.nodeId && (
<div style={{
fontSize: 11,
color: 'var(--text3)',
fontFamily: 'monospace'
}}>
Klicken um zu Node zu springen
</div>
)}
{error.type && (
<div style={{
fontSize: 10,
color: 'var(--text3)',
marginTop: 4,
textTransform: 'uppercase'
}}>
{error.type}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Warnings */}
{warnings.length > 0 && (
<div>
<div
onClick={() => setShowWarnings(!showWarnings)}
style={{
padding: '8px 16px',
background: 'rgba(255, 193, 7, 0.05)',
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
userSelect: 'none',
borderBottom: '1px solid var(--border)'
}}
>
{showWarnings ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
<AlertTriangle size={16} color="#f59e0b" />
<span style={{ fontWeight: 600, fontSize: 13, color: '#f59e0b' }}>
Warnungen ({warnings.length})
</span>
</div>
{showWarnings && (
<div>
{warnings.map((warning, idx) => (
<div
key={idx}
onClick={() => handleItemClick(warning)}
style={{
padding: '10px 16px',
borderBottom: idx < warnings.length - 1 ? '1px solid var(--border)' : 'none',
cursor: warning.nodeId ? 'pointer' : 'default',
background: 'transparent',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (warning.nodeId) e.currentTarget.style.background = 'rgba(255, 193, 7, 0.05)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
}}
>
<div style={{
fontSize: 13,
color: 'var(--text1)',
marginBottom: 4
}}>
{warning.message}
</div>
{warning.nodeId && (
<div style={{
fontSize: 11,
color: 'var(--text3)',
fontFamily: 'monospace'
}}>
Klicken um zu Node zu springen
</div>
)}
{warning.type && (
<div style={{
fontSize: 10,
color: 'var(--text3)',
marginTop: 4,
textTransform: 'uppercase'
}}>
{warning.type}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
)
}

View File

@ -19,6 +19,7 @@ import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPick
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
import { ValidationPanel } from '../components/workflow/panels/ValidationPanel'
import { Toast } from '../components/Toast'
import { ConfirmDialog } from '../components/ConfirmDialog'
import '../styles/workflowEditor.css'
@ -78,6 +79,7 @@ export default function WorkflowEditorPage() {
const endNodeTextareaRef = useRef(null)
const inlineTemplateTextareaRef = useRef(null)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [showValidationPanel, setShowValidationPanel] = useState(true)
// Toast & Confirm Dialog
const [toast, setToast] = useState(null)
@ -122,6 +124,11 @@ export default function WorkflowEditorPage() {
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
setValidationErrors(errors)
setValidationWarnings(warnings)
// Re-show validation panel if there are new errors/warnings
if (errors.length > 0 || warnings.length > 0) {
setShowValidationPanel(true)
}
}, [nodes, edges])
// Warn on browser back/refresh if unsaved changes
@ -380,6 +387,15 @@ export default function WorkflowEditorPage() {
navigate('/admin/prompts')
}
const handleValidationNodeClick = (nodeId) => {
// Select the node to show its config panel
setSelectedNodeId(nodeId)
// TODO: Optional - scroll to node in canvas
// ReactFlow doesn't expose a direct scrollTo API, but we could use fitView
// or manual DOM manipulation if needed
}
const handlePlaceholderSelect = (placeholderString) => {
if (!selectedNode) return
@ -870,6 +886,16 @@ export default function WorkflowEditorPage() {
/>
)}
{/* Validation Panel */}
{showValidationPanel && (validationErrors.length > 0 || validationWarnings.length > 0) && (
<ValidationPanel
errors={validationErrors}
warnings={validationWarnings}
onNodeClick={handleValidationNodeClick}
onClose={() => setShowValidationPanel(false)}
/>
)}
{/* Toast Notification */}
{toast && (
<Toast