diff --git a/src/App.tsx b/src/App.tsx index 92078ed..d59e6ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -157,6 +157,7 @@ function App() { {/* Workspace + Automation2 Editor */} } /> {/* Automation2 Workflows & Tasks */} + } /> } /> {/* Teams Bot Feature Views */} diff --git a/src/api/automation2Api.ts b/src/api/automation2Api.ts index e7d9488..63d9df5 100644 --- a/src/api/automation2Api.ts +++ b/src/api/automation2Api.ts @@ -81,6 +81,20 @@ export interface Automation2Workflow { label: string; graph: Automation2Graph; active?: boolean; + /** Enriched: run count */ + runCount?: number; + /** Enriched: has active (running/paused) run */ + isRunning?: boolean; + /** Enriched: status of active run */ + runStatus?: string; + /** Enriched: nodeId where workflow is stuck (paused) */ + stuckAtNodeId?: string; + /** Enriched: human-readable label for stuck node */ + stuckAtNodeLabel?: string; + /** Enriched: created timestamp (seconds) */ + createdAt?: number; + /** Enriched: last run started timestamp (seconds) */ + lastStartedAt?: number; } // ============================================================================ @@ -242,6 +256,12 @@ export interface Automation2Task { config: Record; status: string; result?: Record; + /** Workflow label (enriched by API) */ + workflowLabel?: string; + /** Unix timestamp ms (from _createdAt) */ + createdAt?: number; + /** Optional due date - configurable in future */ + dueAt?: number; } export async function fetchTasks( diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css b/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css index f0fa536..828453e 100644 --- a/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css @@ -224,17 +224,34 @@ position: relative; min-height: 100%; height: 100%; - overflow: auto; - background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px); - background-size: 20px 20px; + overflow: hidden; border-radius: 8px; + /* Infinite grid: on viewport, moves with pan/zoom via inline style */ + background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px); + background-repeat: repeat; +} + +.canvasContent { + position: absolute; + left: 0; + top: 0; + will-change: transform; + background: transparent; +} + +.canvasGrab { + cursor: grab; +} + +.canvasPanning { + cursor: grabbing; + user-select: none; } .canvasPlaceholder { position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); + left: 2rem; + top: 2rem; text-align: center; color: var(--text-tertiary, #999); border: 2px dashed var(--border-color, #dee2e6); @@ -463,3 +480,20 @@ color: var(--text-secondary, #666); cursor: pointer; } + +.formFieldRemoveButton { + margin-left: auto; + padding: 0.25rem 0.4rem; + border: none; + background: transparent; + color: var(--text-tertiary, #999); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; +} + +.formFieldRemoveButton:hover { + color: var(--danger-color, #dc3545); + background: rgba(220, 53, 69, 0.1); +} diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx index dd2e3c5..8dec46a 100644 --- a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx @@ -2,22 +2,11 @@ * Automation2FlowEditor * * n8n-style flow builder with backend-driven node list. - * Sidebar: all available node types (from API), grouped by category. - * Canvas: placeholder for graph (drag nodes to add). + * Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader. */ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { - FaPlay, - FaCodeBranch, - FaDatabase, - FaPlug, - FaUser, - FaSignInAlt, - FaSpinner, - FaChevronDown, - FaChevronRight, -} from 'react-icons/fa'; +import React, { useState, useEffect, useCallback } from 'react'; +import { FaSpinner } from 'react-icons/fa'; import { useApiRequest } from '../../hooks/useApi'; import { fetchNodeTypes, @@ -34,54 +23,25 @@ import { } from '../../api/automation2Api'; import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { NodeConfigPanel } from './NodeConfigPanel'; +import { NodeSidebar } from './NodeSidebar'; +import { CanvasHeader } from './CanvasHeader'; +import { getCategoryIcon } from './utils'; +import { fromApiGraph, toApiGraph } from './graphUtils'; import styles from './Automation2FlowEditor.module.css'; -// Map category -> icon -const CATEGORY_ICONS: Record = { - trigger: , - input: , - flow: , - data: , - io: , - human: , -}; - -// I/O nodes: group by method/context (KI, Kontext, Outlook, SharePoint, Jira, Trustee, Chatbot) -const IO_METHOD_ORDER = ['ai', 'context', 'outlook', 'sharepoint', 'jira', 'trustee', 'chatbot']; -const IO_METHOD_LABELS: Record> = { - ai: { de: 'KI', en: 'AI', fr: 'IA' }, - context: { de: 'Kontext', en: 'Context', fr: 'Contexte' }, - outlook: { de: 'Outlook', en: 'Outlook', fr: 'Outlook' }, - sharepoint: { de: 'SharePoint', en: 'SharePoint', fr: 'SharePoint' }, - jira: { de: 'Jira', en: 'Jira', fr: 'Jira' }, - trustee: { de: 'Trustee', en: 'Trustee', fr: 'Trustee' }, - chatbot: { de: 'Chatbot', en: 'Chatbot', fr: 'Chatbot' }, -}; - -function getCategoryIcon(categoryId: string): React.ReactNode { - return CATEGORY_ICONS[categoryId] ?? ; -} - -function getIoMethodLabel(method: string, lang: string): string { - return IO_METHOD_LABELS[method]?.[lang] ?? IO_METHOD_LABELS[method]?.en ?? method; -} - const LOG = '[Automation2]'; -function getLabel(text: string | Record | undefined, lang = 'de'): string { - if (!text) return ''; - if (typeof text === 'string') return text; - return (text as Record)[lang] ?? (text as Record).en ?? ''; -} - interface Automation2FlowEditorProps { instanceId: string; language?: string; + /** When set, load this workflow on mount (e.g. from workflows list edit) */ + initialWorkflowId?: string | null; } export const Automation2FlowEditor: React.FC = ({ instanceId, language = 'de', + initialWorkflowId, }) => { const { request } = useApiRequest(); const [nodeTypes, setNodeTypes] = useState([]); @@ -102,93 +62,37 @@ export const Automation2FlowEditor: React.FC = ({ const [selectedNode, setSelectedNode] = useState(null); const [saving, setSaving] = useState(false); - const fromApiGraph = useCallback((graph: Automation2Graph): { nodes: CanvasNode[]; connections: CanvasConnection[] } => { - const nodeMap = new Map(); - nodeTypes.forEach((nt) => { - nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 }); - }); - const nodes: CanvasNode[] = (graph.nodes || []).map((n) => { - const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 }; - return { - id: n.id, - type: n.type, - x: (n as { x?: number }).x ?? 0, - y: (n as { y?: number }).y ?? 0, - title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''), - comment: (n as { comment?: string }).comment, - inputs: io.inputs, - outputs: io.outputs, - parameters: n.parameters ?? {}, - }; - }); - const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`; - const connections: CanvasConnection[] = (graph.connections || []).map((c) => { - const srcNode = nodes.find((n) => n.id === c.source); - const sourceOutput = c.sourceOutput ?? 0; - const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0; - return { - id: connId(c.source, c.target, sourceOutput, c.targetInput ?? 0), - sourceId: c.source, - sourceHandle, - targetId: c.target, - targetHandle: c.targetInput ?? 0, - }; - }); - return { nodes, connections }; - }, [nodeTypes]); - - const toApiGraph = useCallback((): Automation2Graph => { - const nodeMap = new Map(canvasNodes.map((n) => [n.id, n])); - const graph = { - nodes: canvasNodes.map((n) => ({ - id: n.id, - type: n.type, - x: n.x, - y: n.y, - title: n.title, - comment: n.comment, - parameters: n.parameters ?? {}, - })), - connections: canvasConnections.map((c) => { - const srcNode = nodeMap.get(c.sourceId); - const sourceOutput = - srcNode && c.sourceHandle >= srcNode.inputs ? c.sourceHandle - srcNode.inputs : 0; - return { - source: c.sourceId, - target: c.targetId, - sourceOutput, - targetInput: c.targetHandle, - }; - }), - }; - console.log(`${LOG} toApiGraph: canvasNodes=${canvasNodes.length} canvasConnections=${canvasConnections.length} ->`, graph); - return graph; - }, [canvasNodes, canvasConnections]); + const handleFromApiGraph = useCallback( + (graph: Automation2Graph) => { + const { nodes, connections } = fromApiGraph(graph, nodeTypes); + setCanvasNodes(nodes); + setCanvasConnections(connections); + }, + [nodeTypes] + ); const handleExecute = useCallback(async () => { - console.log(`${LOG} handleExecute: start`); - const graph = toApiGraph(); + const graph = toApiGraph(canvasNodes, canvasConnections); if (graph.nodes.length === 0) { - console.warn(`${LOG} handleExecute: keine Nodes, abbrechen`); setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' }); return; } setExecuting(true); setExecuteResult(null); - console.log(`${LOG} handleExecute: rufe executeGraph auf instanceId=${instanceId}`); try { - const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined); - console.log(`${LOG} handleExecute: fertig success=${result?.success}`, result); + const result = await executeGraph( + request, + instanceId, + graph, + currentWorkflowId ?? undefined + ); setExecuteResult(result); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`${LOG} handleExecute: Fehler`, err); - setExecuteResult({ success: false, error: msg }); + setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); } finally { setExecuting(false); - console.log(`${LOG} handleExecute: end`); } - }, [request, instanceId, toApiGraph, currentWorkflowId]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]); const loadWorkflows = useCallback(async () => { if (!instanceId) return; @@ -201,7 +105,7 @@ export const Automation2FlowEditor: React.FC = ({ }, [instanceId, request]); const handleSave = useCallback(async () => { - const graph = toApiGraph(); + const graph = toApiGraph(canvasNodes, canvasConnections); if (graph.nodes.length === 0) { setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' }); return; @@ -219,28 +123,39 @@ export const Automation2FlowEditor: React.FC = ({ setExecuteResult({ success: true } as ExecuteGraphResponse); } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - setExecuteResult({ success: false, error: msg }); + setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); } finally { setSaving(false); } - }, [request, instanceId, toApiGraph, currentWorkflowId]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]); - const handleLoad = useCallback(async (workflowId: string) => { - try { - const wf = await fetchWorkflow(request, instanceId, workflowId); - const graph = wf.graph; - if (graph) { - const { nodes, connections } = fromApiGraph(graph); - setCanvasNodes(nodes); - setCanvasConnections(connections); - setCurrentWorkflowId(wf.id); + const handleLoad = useCallback( + async (workflowId: string) => { + try { + const wf = await fetchWorkflow(request, instanceId, workflowId); + if (wf.graph) handleFromApiGraph(wf.graph); + } catch (err: unknown) { + setExecuteResult({ + success: false, + error: err instanceof Error ? err.message : String(err), + }); } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - setExecuteResult({ success: false, error: msg }); - } - }, [request, instanceId, fromApiGraph]); + }, + [request, instanceId, handleFromApiGraph] + ); + + const handleWorkflowSelect = useCallback( + (workflowId: string | null) => { + setCurrentWorkflowId(workflowId); + if (workflowId) handleLoad(workflowId); + else { + setCanvasNodes([]); + setCanvasConnections([]); + setExecuteResult(null); + } + }, + [handleLoad] + ); const handleNew = useCallback(() => { setCanvasNodes([]); @@ -255,23 +170,18 @@ export const Automation2FlowEditor: React.FC = ({ const loadNodeTypes = useCallback(async () => { if (!instanceId) return; - console.log(`${LOG} loadNodeTypes: start instanceId=${instanceId} language=${language}`); setLoading(true); setError(null); try { const data = await fetchNodeTypes(request, instanceId, language); setNodeTypes(data.nodeTypes); setCategories(data.categories); - console.log(`${LOG} loadNodeTypes: ok ${data.nodeTypes.length} types, ${data.categories.length} categories`); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - console.error(`${LOG} loadNodeTypes: Fehler`, err); - setError(msg); + setError(err instanceof Error ? err.message : String(err)); setNodeTypes([]); setCategories([]); } finally { setLoading(false); - console.log(`${LOG} loadNodeTypes: end`); } }, [instanceId, language, request]); @@ -283,63 +193,29 @@ export const Automation2FlowEditor: React.FC = ({ loadWorkflows(); }, [loadWorkflows]); - const toggleCategory = (id: string) => { + useEffect(() => { + if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) { + handleWorkflowSelect(initialWorkflowId); + } + }, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]); + + const toggleCategory = useCallback((id: string) => { setExpandedCategories((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); - }; + }, []); - const toggleIoMethod = (method: string) => { + const toggleIoMethod = useCallback((method: string) => { setExpandedIoMethods((prev) => { const next = new Set(prev); if (next.has(method)) next.delete(method); else next.add(method); return next; }); - }; - - const filteredNodeTypes = useMemo(() => { - if (!filter.trim()) return nodeTypes; - const q = filter.toLowerCase(); - return nodeTypes.filter( - (n) => - n.id.toLowerCase().includes(q) || - getLabel(n.label, language).toLowerCase().includes(q) || - getLabel(n.description, language).toLowerCase().includes(q) - ); - }, [nodeTypes, filter, language]); - - const groupedByCategory = useMemo(() => { - const map: Record = {}; - filteredNodeTypes.forEach((n) => { - const cat = n.category || 'other'; - if (!map[cat]) map[cat] = []; - map[cat].push(n); - }); - return map; - }, [filteredNodeTypes]); - - // For io category: sub-group by method (KI, Kontext, Outlook, etc.) - const ioSubGroups = useMemo(() => { - const ioNodes = groupedByCategory['io'] || []; - const byMethod: Record = {}; - ioNodes.forEach((n) => { - const method = n.meta?.method ?? n.id.split('.')[1] ?? 'other'; - if (!byMethod[method]) byMethod[method] = []; - byMethod[method].push(n); - }); - const ordered: Array<{ method: string; nodes: NodeType[] }> = []; - IO_METHOD_ORDER.forEach((m) => { - if (byMethod[m]?.length) ordered.push({ method: m, nodes: byMethod[m] }); - }); - Object.keys(byMethod).forEach((m) => { - if (!IO_METHOD_ORDER.includes(m)) ordered.push({ method: m, nodes: byMethod[m] }); - }); - return ordered; - }, [groupedByCategory]); + }, []); const handleDropNodeType = useCallback( (nodeTypeId: string, x: number, y: number) => { @@ -367,25 +243,9 @@ export const Automation2FlowEditor: React.FC = ({ [nodeTypes, language] ); - const orderedCategories = useMemo(() => { - const order = ['trigger', 'input', 'flow', 'data', 'io']; - const seen = new Set(); - const result: string[] = []; - order.forEach((id) => { - if (groupedByCategory[id]) { - result.push(id); - seen.add(id); - } - }); - Object.keys(groupedByCategory).forEach((id) => { - if (!seen.has(id)) result.push(id); - }); - return result; - }, [groupedByCategory]); - - if (loading) { - return ( -
+ const renderSidebar = () => { + if (loading) { + return (

Nodes

@@ -395,16 +255,10 @@ export const Automation2FlowEditor: React.FC = ({

Lade Node-Typen...

-
-
-
-
- ); - } - - if (error) { - return ( -
+ ); + } + if (error) { + return (

Nodes

@@ -416,235 +270,40 @@ export const Automation2FlowEditor: React.FC = ({
-
-
-
-
+ ); + } + return ( + ); - } + }; return (
- {/* Sidebar */} -
-
-

Nodes

- setFilter(e.target.value)} - /> -
-
- {orderedCategories.map((catId) => { - const isExpanded = expandedCategories.has(catId); - const catLabel = categories.find((c) => c.id === catId); - const label = getLabel(catLabel?.label, language) || catId; + {renderSidebar()} - // I/O category: render sub-groups directly (KI, Kontext, Outlook, etc.) – keine E/A-Überschrift - if (catId === 'io' && ioSubGroups.length > 0) { - return ( - - {ioSubGroups.map(({ method, nodes }) => { - const methodLabel = getIoMethodLabel(method, language); - const isMethodExpanded = expandedIoMethods.has(method); - return ( -
- - {isMethodExpanded && - nodes.map((node) => ( -
{ - e.dataTransfer.setData( - 'application/json', - JSON.stringify({ type: node.id }) - ); - e.dataTransfer.effectAllowed = 'copy'; - }} - > -
- {getCategoryIcon(node.category)} -
-
- - {getLabel(node.label, language)} - - - {getLabel(node.description, language)} - -
-
- ))} -
- ); - })} -
- ); - } - - const items = groupedByCategory[catId] || []; - return ( -
- - {isExpanded && - items.map((node) => ( -
{ - e.dataTransfer.setData( - 'application/json', - JSON.stringify({ type: node.id }) - ); - e.dataTransfer.effectAllowed = 'copy'; - }} - > -
- {getCategoryIcon(node.category)} -
-
- - {getLabel(node.label, language)} - - - {getLabel(node.description, language)} - -
-
- ))} -
- ); - })} -
-
- - {/* Canvas */}
-
-
-

Workflow-Editor

- - - - -
- {executeResult && ( -
- {executeResult.success ? ( - <>✓ Ausführung abgeschlossen. - ) : (executeResult as { paused?: boolean }).paused ? ( - <>⏸ Workflow pausiert. Öffne Workflows & Tasks in der Sidebar, um den Task zu bearbeiten. - ) : ( - <>✗ {executeResult.error ?? 'Unbekannter Fehler'} - )} -
- )} -
+ 0} + executeResult={executeResult} + />
void; + onNew: () => void; + onSave: () => void; + onExecute: () => void; + saving: boolean; + executing: boolean; + hasNodes: boolean; + executeResult: ExecuteGraphResponse | null; +} + +export const CanvasHeader: React.FC = ({ + workflows, + currentWorkflowId, + onWorkflowSelect, + onNew, + onSave, + onExecute, + saving, + executing, + hasNodes, + executeResult, +}) => ( +
+
+

+ Workflow-Editor +

+ + + + +
+ {executeResult && ( +
+ {executeResult.success ? ( + <>✓ Ausführung abgeschlossen. + ) : (executeResult as { paused?: boolean }).paused ? ( + <> + ⏸ Workflow pausiert. Öffne Workflows & Tasks in der Sidebar, um den + Task zu bearbeiten. + + ) : ( + <>✗ {executeResult.error ?? 'Unbekannter Fehler'} + )} +
+ )} +
+); diff --git a/src/components/Automation2FlowEditor/FlowCanvas.tsx b/src/components/Automation2FlowEditor/FlowCanvas.tsx index ebb2ffa..f5b1862 100644 --- a/src/components/Automation2FlowEditor/FlowCanvas.tsx +++ b/src/components/Automation2FlowEditor/FlowCanvas.tsx @@ -69,7 +69,20 @@ export const FlowCanvas: React.FC = ({ } | null>(null); const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); const [draggingNodeId, setDraggingNodeId] = useState(null); - const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 }); + const [dragOffset, setDragOffset] = useState({ + startClientX: 0, + startClientY: 0, + startNodeX: 0, + startNodeY: 0, + }); + const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [panning, setPanning] = useState<{ + startX: number; + startY: number; + startPanX: number; + startPanY: number; + } | null>(null); const nodeTypeMap = useMemo(() => { const m: Record = {}; @@ -135,12 +148,12 @@ export const FlowCanvas: React.FC = ({ const { type } = JSON.parse(raw); const el = containerRef.current; const rect = el.getBoundingClientRect(); - const x = e.clientX - rect.left + el.scrollLeft - NODE_WIDTH / 2; - const y = e.clientY - rect.top + el.scrollTop - NODE_HEIGHT / 2; + const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; + const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; onDropNodeType(type, Math.max(0, x), Math.max(0, y)); } catch (_) {} }, - [onDropNodeType] + [onDropNodeType, panOffset, zoom] ); const handleHandleMouseDown = useCallback( @@ -206,16 +219,23 @@ export const FlowCanvas: React.FC = ({ const node = nodes.find((n) => n.id === nodeId); if (!node) return; setDraggingNodeId(nodeId); - setDragOffset({ dx: e.clientX - node.x, dy: e.clientY - node.y }); + setDragOffset({ + startClientX: e.clientX, + startClientY: e.clientY, + startNodeX: node.x, + startNodeY: node.y, + }); }, [nodes]); React.useEffect(() => { if (!draggingNodeId) return; const onMove = (e: MouseEvent) => { + const dx = (e.clientX - dragOffset.startClientX) / zoom; + const dy = (e.clientY - dragOffset.startClientY) / zoom; onNodesChange( nodes.map((n) => n.id === draggingNodeId - ? { ...n, x: e.clientX - dragOffset.dx, y: e.clientY - dragOffset.dy } + ? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + dy } : n ) ); @@ -227,41 +247,79 @@ export const FlowCanvas: React.FC = ({ window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; - }, [draggingNodeId, dragOffset, nodes, onNodesChange]); + }, [draggingNodeId, dragOffset, nodes, onNodesChange, zoom]); - const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0, scrollLeft: 0, scrollTop: 0 }); + const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => { + const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`); + if (hitNode || connectingFrom) return; + setPanning({ + startX: e.clientX, + startY: e.clientY, + startPanX: panOffset.x, + startPanY: panOffset.y, + }); + }, [connectingFrom, panOffset]); + + const handleWheel = useCallback((e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.1 : 0.1; + setZoom((z) => Math.min(2, Math.max(0.25, z + delta))); + }, []); + + React.useEffect(() => { + const el = containerRef.current; + if (!el) return; + el.addEventListener('wheel', handleWheel, { passive: false }); + return () => el.removeEventListener('wheel', handleWheel); + }, [handleWheel]); + + React.useEffect(() => { + if (!panning) return; + const onMove = (e: MouseEvent) => { + setPanOffset({ + x: panning.startPanX + (e.clientX - panning.startX), + y: panning.startPanY + (e.clientY - panning.startY), + }); + }; + const onUp = () => setPanning(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [panning]); + + const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 }); React.useEffect(() => { const el = containerRef.current; if (!el) return; const update = () => { const r = el.getBoundingClientRect(); - setContainerBounds({ left: r.left, top: r.top, scrollLeft: el.scrollLeft, scrollTop: el.scrollTop }); + setContainerBounds({ left: r.left, top: r.top }); }; update(); - el.addEventListener('scroll', update); window.addEventListener('resize', update); - return () => { - el.removeEventListener('scroll', update); - window.removeEventListener('resize', update); - }; + return () => window.removeEventListener('resize', update); }, []); + const CANVAS_SIZE = 8000; const svgBounds = useMemo(() => { - if (nodes.length === 0) return { width: 2000, height: 1500 }; + if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE }; let maxX = 0, maxY = 0; nodes.forEach((n) => { - maxX = Math.max(maxX, n.x + NODE_WIDTH + 100); - maxY = Math.max(maxY, n.y + NODE_HEIGHT + 100); + maxX = Math.max(maxX, n.x + NODE_WIDTH + 200); + maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200); }); - return { width: Math.max(maxX, 2000), height: Math.max(maxY, 1500) }; + return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) }; }, [nodes]); const screenToSvg = useCallback( (clientX: number, clientY: number) => ({ - x: clientX - containerBounds.left + containerBounds.scrollLeft, - y: clientY - containerBounds.top + containerBounds.scrollTop, + x: (clientX - containerBounds.left - panOffset.x) / zoom, + y: (clientY - containerBounds.top - panOffset.y) / zoom, }), - [containerBounds] + [containerBounds, panOffset, zoom] ); const handleDeleteNode = useCallback(() => { @@ -300,12 +358,26 @@ export const FlowCanvas: React.FC = ({ return (
e.preventDefault()} onDrop={handleDrop} + onMouseDown={handleCanvasMouseDown} tabIndex={0} onClick={() => setSelectedNodeId(null)} > +
= ({

Nodes aus der Liste links hierher ziehen.

)} +
); }; diff --git a/src/components/Automation2FlowEditor/NodeConfigPanel.tsx b/src/components/Automation2FlowEditor/NodeConfigPanel.tsx index 2b76400..fcd6ad0 100644 --- a/src/components/Automation2FlowEditor/NodeConfigPanel.tsx +++ b/src/components/Automation2FlowEditor/NodeConfigPanel.tsx @@ -1,16 +1,15 @@ /** * NodeConfigPanel - Configures parameters for input/human nodes. - * Form fields: draggable, required toggle, layout ohne clipping. + * Delegates to config components from configs/. */ import React, { useState, useEffect } from 'react'; -import { FaGripVertical } from 'react-icons/fa'; import type { CanvasNode } from './FlowCanvas'; import type { NodeType } from '../../api/automation2Api'; +import { getLabel } from './utils'; +import { NODE_CONFIG_REGISTRY } from './configs'; import styles from './Automation2FlowEditor.module.css'; -type FormField = { name?: string; type?: string; label?: string; required?: boolean }; - interface NodeConfigPanelProps { node: CanvasNode | null; nodeType: NodeType | undefined; @@ -37,282 +36,21 @@ export const NodeConfigPanel: React.FC = ({ }; if (!node || !node.type.startsWith('input.')) return null; - const nt = nodeType; - const getLabel = (text: string | Record | undefined) => { - if (!text) return ''; - if (typeof text === 'string') return text; - return (text as Record)[language] ?? (text as Record).en ?? ''; - }; - const renderConfig = () => { - switch (node.type) { - case 'input.form': { - const fields = (params.fields as FormField[]) ?? []; - const moveField = (fromIndex: number, toIndex: number) => { - if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return; - const next = [...fields]; - const [removed] = next.splice(fromIndex, 1); - next.splice(toIndex, 0, removed); - updateParam('fields', next); - }; - return ( -
- -
- {fields.map((f, i) => ( -
{ - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - }} - onDrop={(e) => { - e.preventDefault(); - const from = parseInt(e.dataTransfer.getData('text/plain'), 10); - if (!Number.isNaN(from) && from !== i) moveField(from, i); - }} - > -
- { - e.dataTransfer.setData('text/plain', String(i)); - e.dataTransfer.effectAllowed = 'move'; - }} - > - - -
- { - const next = [...fields]; - next[i] = { ...next[i], name: e.target.value }; - updateParam('fields', next); - }} - /> - { - const next = [...fields]; - next[i] = { ...next[i], label: e.target.value }; - updateParam('fields', next); - }} - /> -
-
-
- - -
-
- ))} - -
-
- ); - } - case 'input.approval': - return ( - <> -
- - updateParam('title', e.target.value)} - placeholder="Genehmigungstitel" - /> -
-
- -