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 { WorkflowResultViewer } from '../components/workflow/panels/WorkflowResultViewer'
|
||||
import { InlineTemplateEditor } from '../components/workflow/panels/InlineTemplateEditor'
|
||||
import { Toast } from '../components/Toast'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog'
|
||||
import '../styles/workflowEditor.css'
|
||||
|
||||
// Node-Type Mapping
|
||||
|
|
@ -55,6 +57,10 @@ export default function WorkflowEditorPage() {
|
|||
const endNodeTextareaRef = 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
|
||||
useEffect(() => {
|
||||
async function loadPrompts() {
|
||||
|
|
@ -155,7 +161,7 @@ export default function WorkflowEditorPage() {
|
|||
description: workflowDescription,
|
||||
graph_data
|
||||
})
|
||||
alert('Workflow gespeichert!')
|
||||
setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
|
||||
} else {
|
||||
// Create new
|
||||
console.log('✨ Creating new workflow')
|
||||
|
|
@ -167,7 +173,7 @@ export default function WorkflowEditorPage() {
|
|||
})
|
||||
console.log('✅ Workflow created:', result)
|
||||
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}`)
|
||||
navigate(`/workflow-editor/${result.id}`)
|
||||
}
|
||||
|
|
@ -462,6 +468,33 @@ export default function WorkflowEditorPage() {
|
|||
</button>
|
||||
</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 */}
|
||||
<div className="config-section">
|
||||
<label>Node-Name</label>
|
||||
|
|
@ -624,36 +657,7 @@ export default function WorkflowEditorPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Validation Panel */}
|
||||
{(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>
|
||||
)}
|
||||
{/* Validation Panel - REMOVED (moved to config panel header) */}
|
||||
|
||||
{/* Execution Result Viewer */}
|
||||
{executionResult && (
|
||||
|
|
@ -672,6 +676,28 @@ export default function WorkflowEditorPage() {
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user