From 9b4bad975c6ce350688b0a62cfb7e3ae0bc7c6ae Mon Sep 17 00:00:00 2001 From: idittrich-valueon Date: Sun, 22 Mar 2026 16:17:38 +0100 Subject: [PATCH] first version graphic automation editor --- src/App.tsx | 4 +- src/api/automation2Api.ts | 271 +++++++ .../Automation2FlowEditor.module.css | 465 ++++++++++++ .../Automation2FlowEditor.tsx | 676 ++++++++++++++++++ .../Automation2FlowEditor/FlowCanvas.tsx | 508 +++++++++++++ .../Automation2FlowEditor/NodeConfigPanel.tsx | 318 ++++++++ src/components/Automation2FlowEditor/index.ts | 1 + src/config/pageRegistry.tsx | 3 + src/pages/FeatureView.tsx | 17 +- src/pages/Store.tsx | 8 +- .../views/automation2/Automation2Page.tsx | 31 + .../Automation2WorkflowsTasks.module.css | 210 ++++++ .../Automation2WorkflowsTasksPage.tsx | 361 ++++++++++ src/types/mandate.ts | 9 + 14 files changed, 2879 insertions(+), 3 deletions(-) create mode 100644 src/api/automation2Api.ts create mode 100644 src/components/Automation2FlowEditor/Automation2FlowEditor.module.css create mode 100644 src/components/Automation2FlowEditor/Automation2FlowEditor.tsx create mode 100644 src/components/Automation2FlowEditor/FlowCanvas.tsx create mode 100644 src/components/Automation2FlowEditor/NodeConfigPanel.tsx create mode 100644 src/components/Automation2FlowEditor/index.ts create mode 100644 src/pages/views/automation2/Automation2Page.tsx create mode 100644 src/pages/views/automation2/Automation2WorkflowsTasks.module.css create mode 100644 src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 531ac7f..92078ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -154,8 +154,10 @@ function App() { } /> } /> - {/* Workspace Editor */} + {/* Workspace + Automation2 Editor */} } /> + {/* Automation2 Workflows & Tasks */} + } /> {/* Teams Bot Feature Views */} } /> diff --git a/src/api/automation2Api.ts b/src/api/automation2Api.ts new file mode 100644 index 0000000..e7d9488 --- /dev/null +++ b/src/api/automation2Api.ts @@ -0,0 +1,271 @@ +/** + * Automation2 API + * Node types and graph execution for n8n-style flows. + */ + +import type { ApiRequestOptions } from '../hooks/useApi'; + +const LOG = '[Automation2]'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface NodeTypeParameter { + name: string; + type: string; + required?: boolean; + description?: string; + default?: unknown; +} + +export interface NodeType { + id: string; + category: string; + label: string; + description: string; + parameters: NodeTypeParameter[]; + inputs: number; + outputs: number; + executor: string; + meta?: { + icon?: string; + color?: string; + method?: string; + action?: string; + }; +} + +export interface NodeTypeCategory { + id: string; + label: Record | string; +} + +export interface NodeTypesResponse { + nodeTypes: NodeType[]; + categories: NodeTypeCategory[]; +} + +export interface Automation2GraphNode { + id: string; + type: string; + parameters?: Record; +} + +export interface Automation2Connection { + source: string; + target: string; + sourceOutput?: number; + targetInput?: number; +} + +export interface Automation2Graph { + nodes: Automation2GraphNode[]; + connections: Automation2Connection[]; +} + +export interface ExecuteGraphResponse { + success: boolean; + nodeOutputs?: Record; + error?: string; + stopped?: boolean; + failedNode?: string; + paused?: boolean; + taskId?: string; + runId?: string; + nodeId?: string; +} + +export interface Automation2Workflow { + id: string; + label: string; + graph: Automation2Graph; + active?: boolean; +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +/** + * Fetch node types for the flow builder (backend-driven). + * GET /api/automation2/{instanceId}/node-types?language=de + */ +export async function fetchNodeTypes( + request: ApiRequestFunction, + instanceId: string, + language = 'de' +): Promise { + console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`); + const data = await request({ + url: `/api/automation2/${instanceId}/node-types`, + method: 'get', + params: { language }, + }); + const nodeTypes = data?.nodeTypes ?? []; + const categories = data?.categories ?? []; + console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`); + return { nodeTypes, categories }; +} + +/** + * Execute an automation2 graph. + * POST /api/automation2/{instanceId}/execute + */ +export async function executeGraph( + request: ApiRequestFunction, + instanceId: string, + graph: Automation2Graph, + workflowId?: string +): Promise { + console.log( + `${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`, + { nodes: graph.nodes, connections: graph.connections } + ); + const start = performance.now(); + try { + const result = await request({ + url: `/api/automation2/${instanceId}/execute`, + method: 'post', + data: { graph, workflowId }, + }); + const ms = Math.round(performance.now() - start); + console.log( + `${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`, + result + ); + return result; + } catch (err) { + const ms = Math.round(performance.now() - start); + console.error( + `${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`, + err + ); + throw err; + } +} + +// ------------------------------------------------------------------------- +// Workflows CRUD +// ------------------------------------------------------------------------- + +export async function fetchWorkflows( + request: ApiRequestFunction, + instanceId: string +): Promise { + const data = await request({ + url: `/api/automation2/${instanceId}/workflows`, + method: 'get', + }); + return data?.workflows ?? []; +} + +export async function fetchWorkflow( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise { + return await request({ + url: `/api/automation2/${instanceId}/workflows/${workflowId}`, + method: 'get', + }); +} + +export async function createWorkflow( + request: ApiRequestFunction, + instanceId: string, + body: { label: string; graph: Automation2Graph } +): Promise { + return await request({ + url: `/api/automation2/${instanceId}/workflows`, + method: 'post', + data: body, + }); +} + +export async function updateWorkflow( + request: ApiRequestFunction, + instanceId: string, + workflowId: string, + body: { label?: string; graph?: Automation2Graph } +): Promise { + return await request({ + url: `/api/automation2/${instanceId}/workflows/${workflowId}`, + method: 'put', + data: body, + }); +} + +export async function deleteWorkflow( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise { + await request({ + url: `/api/automation2/${instanceId}/workflows/${workflowId}`, + method: 'delete', + }); +} + +export interface Automation2Run { + id: string; + workflowId: string; + status: string; + nodeOutputs?: Record; + currentNodeId?: string; +} + +export async function fetchWorkflowRuns( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise { + const data = await request({ + url: `/api/automation2/${instanceId}/workflows/${workflowId}/runs`, + method: 'get', + }); + return data?.runs ?? []; +} + +// ------------------------------------------------------------------------- +// Tasks +// ------------------------------------------------------------------------- + +export interface Automation2Task { + id: string; + runId: string; + workflowId: string; + nodeId: string; + nodeType: string; + config: Record; + status: string; + result?: Record; +} + +export async function fetchTasks( + request: ApiRequestFunction, + instanceId: string, + params?: { workflowId?: string; status?: string } +): Promise { + const data = await request({ + url: `/api/automation2/${instanceId}/tasks`, + method: 'get', + params, + }); + return data?.tasks ?? []; +} + +export async function completeTask( + request: ApiRequestFunction, + instanceId: string, + taskId: string, + result: Record +): Promise { + return await request({ + url: `/api/automation2/${instanceId}/tasks/${taskId}/complete`, + method: 'post', + data: { result }, + }); +} diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css b/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css new file mode 100644 index 0000000..f0fa536 --- /dev/null +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.module.css @@ -0,0 +1,465 @@ +/** + * Automation2 Flow Editor Styles + * Sidebar with node list + canvas area. + */ + +.container { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ============================================================================= + SIDEBAR - Node List + ============================================================================= */ + +.sidebar { + flex-shrink: 0; + width: 280px; + display: flex; + flex-direction: column; + background: var(--bg-secondary, #f8f9fa); + border-right: 1px solid var(--border-color, #e0e0e0); + overflow: hidden; +} + +.sidebarHeader { + padding: 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #fff); +} + +.sidebarTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.sidebarSearch { + margin-top: 0.75rem; + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.875rem; + background: var(--bg-primary, #fff); + color: var(--text-primary, #333); +} + +.sidebarSearch::placeholder { + color: var(--text-tertiary, #999); +} + +.sidebarSearch:focus { + outline: none; + border-color: var(--primary-color, #007bff); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15); +} + +.nodeList { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +/* Category Groups */ +.categoryGroup { + margin-bottom: 1rem; +} + + +.categoryHeader { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary, #666); +} + +.categoryIcon { + margin-right: 0.5rem; + font-size: 0.875rem; +} + +.categoryLabel { + flex: 1; +} + +.categoryCount { + background: var(--bg-tertiary, #e9ecef); + color: var(--text-secondary, #666); + padding: 0.125rem 0.5rem; + border-radius: 10px; + font-size: 0.7rem; +} + +/* Node Items */ +.nodeItem { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + margin-bottom: 0.25rem; + border-radius: 6px; + cursor: grab; + transition: background 0.15s; + border: 1px solid transparent; +} + +.nodeItem:hover { + background: var(--bg-hover, #e9ecef); +} + +.nodeItem:active { + cursor: grabbing; +} + +.nodeItemIcon { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + margin-right: 0.75rem; + font-size: 0.875rem; +} + +.nodeItemInfo { + flex: 1; + min-width: 0; +} + +.nodeItemLabel { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.nodeItemDesc { + display: block; + font-size: 0.75rem; + color: var(--text-secondary, #666); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Loading / Error */ +.loading, +.error { + padding: 2rem; + text-align: center; + color: var(--text-secondary, #666); +} + +.error { + color: var(--danger-color, #dc3545); +} + +.retryButton { + margin-top: 0.75rem; + padding: 0.5rem 1rem; + background: var(--primary-color, #007bff); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; +} + +.retryButton:hover { + background: var(--primary-hover, #0056b3); +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ============================================================================= + CANVAS + ============================================================================= */ + +.canvas { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + background: var(--canvas-bg, #fafafa); +} + +.canvasHeader { + flex-shrink: 0; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--bg-primary, #fff); +} + +.canvasTitle { + margin: 0; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary, #666); +} + +.canvasArea { + flex: 1; + padding: 2rem; + min-height: 400px; + overflow: hidden; +} + +.canvasDropZone { + 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; + border-radius: 8px; +} + +.canvasPlaceholder { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: var(--text-tertiary, #999); + border: 2px dashed var(--border-color, #dee2e6); + border-radius: 8px; + padding: 2rem 3rem; +} + +.canvasPlaceholder p { + margin: 0.25rem 0; + font-size: 0.875rem; +} + +/* Canvas Nodes */ +.canvasNode { + position: absolute; + border-radius: 8px; + border: 2px solid; + cursor: grab; + overflow: visible; +} + +.canvasNode:active { + cursor: grabbing; +} + +.canvasNodeSelected { + box-shadow: 0 0 0 2px var(--primary-color, #007bff); +} + +.canvasNodeContent { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.4rem 0.6rem; + height: 100%; + box-sizing: border-box; +} + +.canvasNodeIcon { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + font-size: 0.9rem; +} + +.canvasNodeText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.canvasNodeTitle { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.canvasNodeTitle:hover { + text-decoration: underline; +} + +.canvasNodeComment { + font-size: 0.7rem; + color: var(--text-tertiary, #999); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; + min-height: 1em; +} + +.canvasNodeComment:hover { + text-decoration: underline; +} + +.canvasNodeInput { + width: 100%; + padding: 0.15rem 0.25rem; + font-size: 0.875rem; + border: 1px solid var(--primary-color, #007bff); + border-radius: 4px; + outline: none; +} + +/* Connection Handles */ +.handle { + position: absolute; + border-radius: 50%; + background: var(--bg-primary, #fff); + border: 2px solid var(--border-color, #666); + cursor: crosshair; + z-index: 2; +} + +.handle:hover, +.handleConnectable { + border-color: var(--primary-color, #007bff); + background: var(--primary-color, #007bff); +} + +.handleInput { + cursor: copy; +} + +/* Node Config Panel */ +.nodeConfigPanel { + padding: 1rem; + background: var(--bg-primary, #fff); + border-left: 1px solid var(--border-color, #e0e0e0); + width: 280px; + flex-shrink: 0; + overflow-y: auto; + min-width: 0; +} + +.nodeConfigPanel h4 { + margin: 0 0 0.75rem 0; + font-size: 0.9rem; +} + +.nodeConfigPanel label { + display: block; + font-size: 0.75rem; + color: var(--text-secondary, #666); + margin-top: 0.5rem; + margin-bottom: 0.25rem; +} + +.nodeConfigPanel input[type='text'], +.nodeConfigPanel input[type='number'], +.nodeConfigPanel select, +.nodeConfigPanel textarea { + width: 100%; + padding: 0.4rem; + font-size: 0.875rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 4px; +} + +.nodeConfigPanel textarea { + min-height: 60px; +} + +.nodeConfigPanel button { + margin-top: 0.5rem; + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + background: var(--primary-color, #007bff); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +/* Form fields editor (input.form) */ +.formFieldsList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.formFieldRow { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem; + background: var(--bg-secondary, #f8f9fa); + border-radius: 6px; + border: 1px solid var(--border-color, #e0e0e0); +} + +.formFieldRowHeader { + display: flex; + align-items: flex-start; + gap: 0.35rem; +} + +.formFieldDragHandle { + flex-shrink: 0; + padding: 0.25rem; + cursor: grab; + color: var(--text-tertiary, #999); + align-self: stretch; + display: flex; + align-items: center; +} + +.formFieldDragHandle:active { + cursor: grabbing; +} + +.formFieldDragHandle:hover { + color: var(--text-secondary, #666); +} + +.formFieldInputs { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.formFieldRowFooter { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.formFieldRequiredLabel { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + cursor: pointer; +} diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx new file mode 100644 index 0000000..dd2e3c5 --- /dev/null +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx @@ -0,0 +1,676 @@ +/** + * 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). + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + FaPlay, + FaCodeBranch, + FaDatabase, + FaPlug, + FaUser, + FaSignInAlt, + FaSpinner, + FaChevronDown, + FaChevronRight, +} from 'react-icons/fa'; +import { useApiRequest } from '../../hooks/useApi'; +import { + fetchNodeTypes, + executeGraph, + fetchWorkflows, + fetchWorkflow, + createWorkflow, + updateWorkflow, + type NodeType, + type NodeTypeCategory, + type Automation2Graph, + type Automation2Workflow, + type ExecuteGraphResponse, +} from '../../api/automation2Api'; +import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; +import { NodeConfigPanel } from './NodeConfigPanel'; +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; +} + +export const Automation2FlowEditor: React.FC = ({ + instanceId, + language = 'de', +}) => { + const { request } = useApiRequest(); + const [nodeTypes, setNodeTypes] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + const [expandedCategories, setExpandedCategories] = useState>( + new Set(['trigger', 'input', 'flow', 'data']) + ); + const [expandedIoMethods, setExpandedIoMethods] = useState>(new Set()); + const [canvasNodes, setCanvasNodes] = useState([]); + const [canvasConnections, setCanvasConnections] = useState([]); + const [executing, setExecuting] = useState(false); + const [executeResult, setExecuteResult] = useState(null); + const [workflows, setWorkflows] = useState([]); + const [currentWorkflowId, setCurrentWorkflowId] = useState(null); + 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 handleExecute = useCallback(async () => { + console.log(`${LOG} handleExecute: start`); + const graph = toApiGraph(); + 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); + 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 }); + } finally { + setExecuting(false); + console.log(`${LOG} handleExecute: end`); + } + }, [request, instanceId, toApiGraph, currentWorkflowId]); + + const loadWorkflows = useCallback(async () => { + if (!instanceId) return; + try { + const items = await fetchWorkflows(request, instanceId); + setWorkflows(items); + } catch (e) { + console.error(`${LOG} loadWorkflows failed`, e); + } + }, [instanceId, request]); + + const handleSave = useCallback(async () => { + const graph = toApiGraph(); + if (graph.nodes.length === 0) { + setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' }); + return; + } + setSaving(true); + try { + if (currentWorkflowId) { + await updateWorkflow(request, instanceId, currentWorkflowId, { graph }); + setExecuteResult({ success: true } as ExecuteGraphResponse); + } else { + const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow'; + const created = await createWorkflow(request, instanceId, { label, graph }); + setCurrentWorkflowId(created.id); + setWorkflows((prev) => [...prev, created]); + setExecuteResult({ success: true } as ExecuteGraphResponse); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setExecuteResult({ success: false, error: msg }); + } finally { + setSaving(false); + } + }, [request, instanceId, toApiGraph, 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); + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setExecuteResult({ success: false, error: msg }); + } + }, [request, instanceId, fromApiGraph]); + + const handleNew = useCallback(() => { + setCanvasNodes([]); + setCanvasConnections([]); + setCurrentWorkflowId(null); + setExecuteResult(null); + }, []); + + const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record) => { + setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, parameters } : n))); + }, []); + + 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); + setNodeTypes([]); + setCategories([]); + } finally { + setLoading(false); + console.log(`${LOG} loadNodeTypes: end`); + } + }, [instanceId, language, request]); + + useEffect(() => { + loadNodeTypes(); + }, [loadNodeTypes]); + + useEffect(() => { + loadWorkflows(); + }, [loadWorkflows]); + + const toggleCategory = (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) => { + 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) => { + const nt = nodeTypes.find((n) => n.id === nodeTypeId); + if (!nt) return; + const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + const label = + typeof nt.label === 'string' ? nt.label : (nt.label as Record)?.[language] ?? nt.id; + setCanvasNodes((prev) => [ + ...prev, + { + id, + type: nodeTypeId, + x, + y, + label, + title: label, + color: nt.meta?.color, + inputs: nt.inputs ?? 1, + outputs: nt.outputs ?? 1, + parameters: {}, + }, + ]); + }, + [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 ( +
+
+
+

Nodes

+
+
+ +

Lade Node-Typen...

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

Nodes

+
+
+

{error}

+ +
+
+
+
+
+
+ ); + } + + 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; + + // 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'} + )} +
+ )} +
+
+
+ node.title ?? node.label ?? node.type} + getCategoryIcon={getCategoryIcon} + onSelectionChange={setSelectedNode} + /> +
+ {selectedNode?.type?.startsWith('input.') && ( + nt.id === selectedNode.type)} + language={language} + onParametersChange={handleNodeParametersChange} + /> + )} +
+
+
+ ); +}; + +export default Automation2FlowEditor; diff --git a/src/components/Automation2FlowEditor/FlowCanvas.tsx b/src/components/Automation2FlowEditor/FlowCanvas.tsx new file mode 100644 index 0000000..ebb2ffa --- /dev/null +++ b/src/components/Automation2FlowEditor/FlowCanvas.tsx @@ -0,0 +1,508 @@ +/** + * FlowCanvas - Workflow graph canvas with nodes and connection lines. + * Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { NodeType } from '../../api/automation2Api'; +import styles from './Automation2FlowEditor.module.css'; + +export interface CanvasNode { + id: string; + type: string; + x: number; + y: number; + label?: string; + title?: string; + comment?: string; + color?: string; + inputs: number; + outputs: number; + parameters?: Record; +} + +export interface CanvasConnection { + id: string; + sourceId: string; + sourceHandle: number; + targetId: string; + targetHandle: number; +} + +const NODE_WIDTH = 200; +const NODE_HEIGHT = 72; +const HANDLE_SIZE = 12; +const HANDLE_OFFSET = HANDLE_SIZE / 2; + +interface FlowCanvasProps { + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; + onNodesChange: (nodes: CanvasNode[]) => void; + onConnectionsChange: (connections: CanvasConnection[]) => void; + onDropNodeType: (nodeTypeId: string, x: number, y: number) => void; + getLabel: (node: CanvasNode) => string; + getCategoryIcon: (category: string) => React.ReactNode; + onSelectionChange?: (node: CanvasNode | null) => void; +} + +export const FlowCanvas: React.FC = ({ + nodes, + connections, + nodeTypes, + onNodesChange, + onConnectionsChange, + onDropNodeType, + getLabel, + getCategoryIcon, + onSelectionChange, +}) => { + const containerRef = useRef(null); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [editingNodeId, setEditingNodeId] = useState(null); + const [editingField, setEditingField] = useState<'title' | 'comment' | null>(null); + const [connectingFrom, setConnectingFrom] = useState<{ + nodeId: string; + handleIndex: number; + x: number; + y: number; + } | 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 nodeTypeMap = useMemo(() => { + const m: Record = {}; + nodeTypes.forEach((nt) => { + m[nt.id] = nt; + }); + return m; + }, [nodeTypes]); + + useEffect(() => { + if (onSelectionChange) { + const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; + onSelectionChange(node); + } + }, [selectedNodeId, nodes, onSelectionChange]); + + const getHandlePosition = useCallback( + (node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => { + const isOutput = handleIndex >= node.inputs; + const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex; + const ioCount = isOutput ? node.outputs : node.inputs; + + const w = NODE_WIDTH; + const h = NODE_HEIGHT; + const centerX = node.x + w / 2; + const centerY = node.y + h / 2; + + if (isOutput) { + if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' }; + if (ioCount === 2) { + return ioIndex === 0 + ? { x: node.x + w, y: node.y + h / 3, side: 'right' } + : { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' }; + } + const step = h / (ioCount + 1); + return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' }; + } else { + if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' }; + if (ioCount === 2) { + return ioIndex === 0 + ? { x: node.x, y: node.y + h / 3, side: 'left' } + : { x: node.x, y: node.y + (2 * h) / 3, side: 'left' }; + } + const step = h / (ioCount + 1); + return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' }; + } + }, + [] + ); + + const getUsedTargetHandles = useMemo(() => { + const used = new Set(); + connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`)); + return used; + }, [connections]); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + const raw = e.dataTransfer.getData('application/json'); + if (!raw || !containerRef.current) return; + try { + 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; + onDropNodeType(type, Math.max(0, x), Math.max(0, y)); + } catch (_) {} + }, + [onDropNodeType] + ); + + const handleHandleMouseDown = useCallback( + (e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => { + e.stopPropagation(); + if (!isOutput) return; + const node = nodes.find((n) => n.id === nodeId); + if (!node) return; + const pos = getHandlePosition(node, handleIndex); + setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y }); + setDragPos({ x: e.clientX, y: e.clientY }); + }, + [nodes, getHandlePosition] + ); + + const handleHandleMouseUp = useCallback( + (e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => { + e.stopPropagation(); + if (!connectingFrom || connectingFrom.nodeId === targetNodeId) { + setConnectingFrom(null); + setDragPos(null); + return; + } + const key = `${targetNodeId}-${targetHandleIndex}`; + if (getUsedTargetHandles.has(key)) { + setConnectingFrom(null); + setDragPos(null); + return; + } + const targetNode = nodes.find((n) => n.id === targetNodeId); + if (!targetNode) return; + if (targetHandleIndex >= targetNode.inputs) return; + const newConn: CanvasConnection = { + id: `c_${Date.now()}`, + sourceId: connectingFrom.nodeId, + sourceHandle: connectingFrom.handleIndex, + targetId: targetNodeId, + targetHandle: targetHandleIndex, + }; + onConnectionsChange([...connections, newConn]); + setConnectingFrom(null); + setDragPos(null); + }, + [connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange] + ); + + React.useEffect(() => { + if (!connectingFrom || !dragPos) return; + const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY }); + const onUp = () => { + setConnectingFrom(null); + setDragPos(null); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [connectingFrom, dragPos]); + + const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => { + const node = nodes.find((n) => n.id === nodeId); + if (!node) return; + setDraggingNodeId(nodeId); + setDragOffset({ dx: e.clientX - node.x, dy: e.clientY - node.y }); + }, [nodes]); + + React.useEffect(() => { + if (!draggingNodeId) return; + const onMove = (e: MouseEvent) => { + onNodesChange( + nodes.map((n) => + n.id === draggingNodeId + ? { ...n, x: e.clientX - dragOffset.dx, y: e.clientY - dragOffset.dy } + : n + ) + ); + }; + const onUp = () => setDraggingNodeId(null); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [draggingNodeId, dragOffset, nodes, onNodesChange]); + + const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0, scrollLeft: 0, scrollTop: 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 }); + }; + update(); + el.addEventListener('scroll', update); + window.addEventListener('resize', update); + return () => { + el.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + }; + }, []); + + const svgBounds = useMemo(() => { + if (nodes.length === 0) return { width: 2000, height: 1500 }; + 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); + }); + return { width: Math.max(maxX, 2000), height: Math.max(maxY, 1500) }; + }, [nodes]); + + const screenToSvg = useCallback( + (clientX: number, clientY: number) => ({ + x: clientX - containerBounds.left + containerBounds.scrollLeft, + y: clientY - containerBounds.top + containerBounds.scrollTop, + }), + [containerBounds] + ); + + const handleDeleteNode = useCallback(() => { + if (!selectedNodeId) return; + onNodesChange(nodes.filter((n) => n.id !== selectedNodeId)); + onConnectionsChange( + connections.filter((c) => c.sourceId !== selectedNodeId && c.targetId !== selectedNodeId) + ); + setSelectedNodeId(null); + setEditingNodeId(null); + setEditingField(null); + }, [selectedNodeId, nodes, connections, onNodesChange, onConnectionsChange]); + + React.useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + e.preventDefault(); + handleDeleteNode(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [handleDeleteNode, selectedNodeId]); + + const handleNodeUpdate = useCallback( + (nodeId: string, updates: Partial>) => { + onNodesChange( + nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n)) + ); + }, + [nodes, onNodesChange] + ); + + return ( +
e.preventDefault()} + onDrop={handleDrop} + tabIndex={0} + onClick={() => setSelectedNodeId(null)} + > + + + + + + + {connections.map((c) => { + const srcNode = nodes.find((n) => n.id === c.sourceId); + const tgtNode = nodes.find((n) => n.id === c.targetId); + if (!srcNode || !tgtNode) return null; + const src = getHandlePosition(srcNode, c.sourceHandle); + const tgt = getHandlePosition(tgtNode, c.targetHandle); + const dx = tgt.x - src.x; + const path = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`; + return ( + + ); + })} + {connectingFrom && dragPos && (() => { + const end = screenToSvg(dragPos.x, dragPos.y); + return ( + + ); + })()} + + {nodes.map((node) => { + const nt = nodeTypeMap[node.type]; + const category = nt?.category ?? 'io'; + const color = node.color ?? nt?.meta?.color ?? '#00BCD4'; + const totalHandles = node.inputs + node.outputs; + const handles: Array<{ index: number; isOutput: boolean }> = []; + for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false }); + for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true }); + + const isSelected = selectedNodeId === node.id; + const isEditingTitle = editingNodeId === node.id && editingField === 'title'; + const isEditingComment = editingNodeId === node.id && editingField === 'comment'; + const displayTitle = node.title ?? node.label ?? getLabel(node); + const displayComment = node.comment ?? ''; + + return ( +
e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation(); + setSelectedNodeId(node.id); + handleNodeMouseDown(e, node.id); + }} + > + {handles.map(({ index, isOutput }) => { + const pos = getHandlePosition(node, index); + const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`); + const canConnect = isOutput || (!used && connectingFrom); + return ( +
handleHandleMouseDown(e, node.id, index, isOutput)} + onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)} + /> + ); + })} +
+
+ {getCategoryIcon(category)} +
+
+ {isEditingTitle ? ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value }); + setEditingNodeId(null); + setEditingField(null); + } + if (e.key === 'Escape') { + setEditingNodeId(null); + setEditingField(null); + } + }} + onBlur={(e) => { + handleNodeUpdate(node.id, { title: e.target.value }); + setEditingNodeId(null); + setEditingField(null); + }} + onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })} + /> + ) : ( + { + e.stopPropagation(); + setEditingNodeId(node.id); + setEditingField('title'); + }} + > + {displayTitle} + + )} + {isEditingComment ? ( + e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleNodeUpdate(node.id, { comment: (e.target as HTMLInputElement).value }); + setEditingNodeId(null); + setEditingField(null); + } + if (e.key === 'Escape') { + setEditingNodeId(null); + setEditingField(null); + } + }} + onBlur={(e) => { + handleNodeUpdate(node.id, { comment: e.target.value }); + setEditingNodeId(null); + setEditingField(null); + }} + onChange={(e) => handleNodeUpdate(node.id, { comment: e.target.value })} + /> + ) : ( + { + e.stopPropagation(); + setEditingNodeId(node.id); + setEditingField('comment'); + }} + > + {displayComment || 'Doppelklick für Kommentar'} + + )} +
+
+
+ ); + })} + {nodes.length === 0 && ( +
+

Nodes aus der Liste links hierher ziehen.

+
+ )} +
+ ); +}; diff --git a/src/components/Automation2FlowEditor/NodeConfigPanel.tsx b/src/components/Automation2FlowEditor/NodeConfigPanel.tsx new file mode 100644 index 0000000..2b76400 --- /dev/null +++ b/src/components/Automation2FlowEditor/NodeConfigPanel.tsx @@ -0,0 +1,318 @@ +/** + * NodeConfigPanel - Configures parameters for input/human nodes. + * Form fields: draggable, required toggle, layout ohne clipping. + */ + +import React, { useState, useEffect } from 'react'; +import { FaGripVertical } from 'react-icons/fa'; +import type { CanvasNode } from './FlowCanvas'; +import type { NodeType } from '../../api/automation2Api'; +import styles from './Automation2FlowEditor.module.css'; + +type FormField = { name?: string; type?: string; label?: string; required?: boolean }; + +interface NodeConfigPanelProps { + node: CanvasNode | null; + nodeType: NodeType | undefined; + language: string; + onParametersChange: (nodeId: string, parameters: Record) => void; +} + +export const NodeConfigPanel: React.FC = ({ + node, + nodeType, + language, + onParametersChange, +}) => { + const [params, setParams] = useState>({}); + + useEffect(() => { + setParams(node?.parameters ?? {}); + }, [node?.id, node?.parameters]); + + const updateParam = (key: string, value: unknown) => { + const next = { ...params, [key]: value }; + setParams(next); + if (node) onParametersChange(node.id, next); + }; + + 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" + /> +
+
+ +