feat: Workflow Editor UX improvements - validation and notifications
**Problem 1: Validation footer covers mobile menu** - Fixed bottom validation panel (z-index 1000) overlapped mobile nav - Solution: Removed bottom panel, added inline validation in config panel header **Problem 2: Alert dialogs for save success** - alert() blocks UI and requires OK click for every save - Solution: Toast notifications (auto-close after 3s, non-blocking) **Problem 3: Validation shows only counts, not details** - Footer showed "1 Error, 2 Warnings" without details - Solution: Inline display shows all error/warning messages with click-to-navigate **New Components:** - Toast.jsx: Auto-closing notifications (success/error/warning/info) - ConfirmDialog.jsx: Modal confirmation dialogs (for future save-on-close) **Changes:** - WorkflowEditorPage: Inline validation in config panel, toast state - Removed fixed bottom .validation-panel (no mobile overlap) - Toast for save success instead of alert() **Still TODO (separate commit):** - Save confirmation when closing/switching nodes with unsaved changes - Dirty state tracking Part 3: Inline Prompts - UX polish (validation + notifications)
This commit is contained in:
parent
8d89b23db1
commit
3541c416f9
106
frontend/src/components/ConfirmDialog.jsx
Normal file
106
frontend/src/components/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
/**
|
||||||
|
* ConfirmDialog Component
|
||||||
|
*
|
||||||
|
* Modal confirmation dialog
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - message: string
|
||||||
|
* - onConfirm: callback when confirmed
|
||||||
|
* - onCancel: callback when cancelled
|
||||||
|
* - confirmText: string (default: 'OK')
|
||||||
|
* - cancelText: string (default: 'Abbrechen')
|
||||||
|
* - type: 'warning' | 'danger' | 'info' (default: 'warning')
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
confirmText = 'OK',
|
||||||
|
cancelText = 'Abbrechen',
|
||||||
|
type = 'warning'
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
warning: '#FFC107',
|
||||||
|
danger: 'var(--danger)',
|
||||||
|
info: 'var(--accent)'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 20000
|
||||||
|
}}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '90%',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.3)'
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
lineHeight: '1.5'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
color: 'var(--text1)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: colors[type],
|
||||||
|
color: type === 'warning' ? '#000' : 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
frontend/src/components/Toast.jsx
Normal file
95
frontend/src/components/Toast.jsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast Notification Component
|
||||||
|
*
|
||||||
|
* Auto-closing notification that appears at the top of the screen
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* - message: string
|
||||||
|
* - type: 'success' | 'error' | 'warning' | 'info'
|
||||||
|
* - duration: number (ms, default 3000)
|
||||||
|
* - onClose: callback when toast closes
|
||||||
|
*/
|
||||||
|
export function Toast({ message, type = 'info', duration = 3000, onClose }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (onClose) onClose()
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [duration, onClose])
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
success: {
|
||||||
|
background: '#4CAF50',
|
||||||
|
color: 'white',
|
||||||
|
icon: '✅'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
icon: '❌'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: '#FFC107',
|
||||||
|
color: '#856404',
|
||||||
|
icon: '⚠️'
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
background: 'var(--accent)',
|
||||||
|
color: 'white',
|
||||||
|
icon: 'ℹ️'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = styles[type] || styles.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '80px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
background: style.background,
|
||||||
|
color: style.color,
|
||||||
|
padding: '12px 24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
zIndex: 10000,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: '300px',
|
||||||
|
maxWidth: '600px',
|
||||||
|
animation: 'slideDown 0.3s ease-out'
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '18px' }}>{style.icon}</span>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animation CSS if not already in global styles
|
||||||
|
const styleElement = document.createElement('style')
|
||||||
|
styleElement.textContent = `
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if (!document.querySelector('style[data-toast-styles]')) {
|
||||||
|
styleElement.setAttribute('data-toast-styles', 'true')
|
||||||
|
document.head.appendChild(styleElement)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@ 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 { Toast } from '../components/Toast'
|
||||||
|
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||||
import '../styles/workflowEditor.css'
|
import '../styles/workflowEditor.css'
|
||||||
|
|
||||||
// Node-Type Mapping
|
// Node-Type Mapping
|
||||||
|
|
@ -55,6 +57,10 @@ export default function WorkflowEditorPage() {
|
||||||
const endNodeTextareaRef = useRef(null)
|
const endNodeTextareaRef = useRef(null)
|
||||||
const inlineTemplateTextareaRef = useRef(null)
|
const inlineTemplateTextareaRef = useRef(null)
|
||||||
|
|
||||||
|
// Toast & Confirm Dialog
|
||||||
|
const [toast, setToast] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
// Load available basis prompts for Analysis nodes
|
// Load available basis prompts for Analysis nodes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadPrompts() {
|
async function loadPrompts() {
|
||||||
|
|
@ -155,7 +161,7 @@ export default function WorkflowEditorPage() {
|
||||||
description: workflowDescription,
|
description: workflowDescription,
|
||||||
graph_data
|
graph_data
|
||||||
})
|
})
|
||||||
alert('Workflow gespeichert!')
|
setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
console.log('✨ Creating new workflow')
|
console.log('✨ Creating new workflow')
|
||||||
|
|
@ -167,7 +173,7 @@ export default function WorkflowEditorPage() {
|
||||||
})
|
})
|
||||||
console.log('✅ Workflow created:', result)
|
console.log('✅ Workflow created:', result)
|
||||||
setCurrentPrompt({ id: result.id, slug: result.slug, name: workflowName })
|
setCurrentPrompt({ id: result.id, slug: result.slug, name: workflowName })
|
||||||
alert('Workflow erstellt!')
|
setToast({ message: '✅ Workflow erstellt!', type: 'success' })
|
||||||
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
console.log('🚀 Navigating to:', `/workflow-editor/${result.id}`)
|
||||||
navigate(`/workflow-editor/${result.id}`)
|
navigate(`/workflow-editor/${result.id}`)
|
||||||
}
|
}
|
||||||
|
|
@ -462,6 +468,33 @@ export default function WorkflowEditorPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Validation Display */}
|
||||||
|
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
|
||||||
|
<div style={{
|
||||||
|
background: validationErrors.length > 0 ? '#ffebee' : '#fff3cd',
|
||||||
|
border: `1px solid ${validationErrors.length > 0 ? 'var(--danger)' : '#FFC107'}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: '8px', color: validationErrors.length > 0 ? 'var(--danger)' : '#856404' }}>
|
||||||
|
{validationErrors.length > 0 ? `❌ ${validationErrors.length} Fehler` : `⚠️ ${validationWarnings.length} Warnungen`}
|
||||||
|
</div>
|
||||||
|
{validationErrors.map((err, i) => (
|
||||||
|
<div key={`err-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: 'var(--danger)', cursor: err.nodeId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => err.nodeId && setSelectedNodeId(err.nodeId)}>
|
||||||
|
• {err.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{validationWarnings.map((warn, i) => (
|
||||||
|
<div key={`warn-${i}`} style={{ fontSize: '13px', marginBottom: '4px', color: '#856404', cursor: warn.nodeId ? 'pointer' : 'default' }}
|
||||||
|
onClick={() => warn.nodeId && setSelectedNodeId(warn.nodeId)}>
|
||||||
|
• {warn.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Basis-Konfiguration */}
|
{/* Basis-Konfiguration */}
|
||||||
<div className="config-section">
|
<div className="config-section">
|
||||||
<label>Node-Name</label>
|
<label>Node-Name</label>
|
||||||
|
|
@ -624,36 +657,7 @@ export default function WorkflowEditorPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Panel */}
|
{/* Validation Panel - REMOVED (moved to config panel header) */}
|
||||||
{(validationErrors.length > 0 || validationWarnings.length > 0) && (
|
|
||||||
<div className="validation-panel">
|
|
||||||
{validationErrors.map((err, i) => (
|
|
||||||
<div key={i} className="validation-error" onClick={() => {
|
|
||||||
if (err.nodeId) {
|
|
||||||
setSelectedNodeId(err.nodeId)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
❌ {err.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{validationWarnings.map((warn, i) => (
|
|
||||||
<div key={i} className="validation-warning" onClick={() => {
|
|
||||||
if (warn.nodeId) {
|
|
||||||
setSelectedNodeId(warn.nodeId)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
⚠️ {warn.message}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{validationErrors.length === 0 && validationWarnings.length > 0 && (
|
|
||||||
<div className="validation-success">
|
|
||||||
✅ Workflow ist valide ({validationWarnings.length} Warnungen)
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Execution Result Viewer */}
|
{/* Execution Result Viewer */}
|
||||||
{executionResult && (
|
{executionResult && (
|
||||||
|
|
@ -672,6 +676,28 @@ export default function WorkflowEditorPage() {
|
||||||
onClose={() => setShowPlaceholderPicker(false)}
|
onClose={() => setShowPlaceholderPicker(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
duration={toast.duration || 3000}
|
||||||
|
onClose={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Confirm Dialog */}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
message={confirmDialog.message}
|
||||||
|
onConfirm={confirmDialog.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
confirmText={confirmDialog.confirmText}
|
||||||
|
cancelText={confirmDialog.cancelText}
|
||||||
|
type={confirmDialog.type}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user