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 { 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user