feat: Validierungs-Panel mit Details und Click-to-Jump
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:
parent
3fa01dd686
commit
549c31431e
246
frontend/src/components/workflow/panels/ValidationPanel.jsx
Normal file
246
frontend/src/components/workflow/panels/ValidationPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import { PlaceholderPicker } from '../components/workflow/panels/PlaceholderPick
|
||||||
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
import { WorkflowExecutePanel } from '../components/workflow/panels/WorkflowExecutePanel'
|
||||||
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
import { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
||||||
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
||||||
|
import { ValidationPanel } from '../components/workflow/panels/ValidationPanel'
|
||||||
import { Toast } from '../components/Toast'
|
import { Toast } from '../components/Toast'
|
||||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import '../styles/workflowEditor.css'
|
import '../styles/workflowEditor.css'
|
||||||
|
|
@ -78,6 +79,7 @@ export default function WorkflowEditorPage() {
|
||||||
const endNodeTextareaRef = useRef(null)
|
const endNodeTextareaRef = useRef(null)
|
||||||
const inlineTemplateTextareaRef = useRef(null)
|
const inlineTemplateTextareaRef = useRef(null)
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||||
|
const [showValidationPanel, setShowValidationPanel] = useState(true)
|
||||||
|
|
||||||
// Toast & Confirm Dialog
|
// Toast & Confirm Dialog
|
||||||
const [toast, setToast] = useState(null)
|
const [toast, setToast] = useState(null)
|
||||||
|
|
@ -122,6 +124,11 @@ export default function WorkflowEditorPage() {
|
||||||
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
const { errors, warnings } = validateWorkflowGraph(nodes, edges)
|
||||||
setValidationErrors(errors)
|
setValidationErrors(errors)
|
||||||
setValidationWarnings(warnings)
|
setValidationWarnings(warnings)
|
||||||
|
|
||||||
|
// Re-show validation panel if there are new errors/warnings
|
||||||
|
if (errors.length > 0 || warnings.length > 0) {
|
||||||
|
setShowValidationPanel(true)
|
||||||
|
}
|
||||||
}, [nodes, edges])
|
}, [nodes, edges])
|
||||||
|
|
||||||
// Warn on browser back/refresh if unsaved changes
|
// Warn on browser back/refresh if unsaved changes
|
||||||
|
|
@ -380,6 +387,15 @@ export default function WorkflowEditorPage() {
|
||||||
navigate('/admin/prompts')
|
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) => {
|
const handlePlaceholderSelect = (placeholderString) => {
|
||||||
if (!selectedNode) return
|
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 Notification */}
|
||||||
{toast && (
|
{toast && (
|
||||||
<Toast
|
<Toast
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user