diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index 51bde60..b043a5a 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -20,7 +20,7 @@ display: flex; flex-direction: column; background: var(--bg-secondary, #f8f9fa); - border-right: 1px solid var(--border-color, #e0e0e0); + border-left: 1px solid var(--border-color, #e0e0e0); overflow: hidden; } diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index aba7b53..ca1889e 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -20,6 +20,8 @@ import { publishVersion, unpublishVersion, archiveVersion, + createTemplateFromWorkflow, + copyTemplate, type NodeType, type NodeTypeCategory, type Automation2Graph, @@ -27,12 +29,14 @@ import { type ExecuteGraphResponse, type WorkflowEntryPoint, type AutoVersion, + type AutoTemplateScope, } from '../../../api/workflowApi'; import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; import { WorkflowConfigurationModal } from './WorkflowConfigurationModal'; +import { TemplatePicker } from './TemplatePicker'; import { getCategoryIcon } from '../nodes/shared/utils'; import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { @@ -43,6 +47,7 @@ import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { usePrompt } from '../../../hooks/usePrompt'; import { EditorChatPanel } from './EditorChatPanel'; +import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import { RunTracingPanel } from './RunTracingPanel'; import styles from './Automation2FlowEditor.module.css'; @@ -56,12 +61,20 @@ interface Automation2FlowEditorProps { language?: string; /** When set, load this workflow on mount (e.g. from workflows list edit) */ initialWorkflowId?: string | null; + pendingFiles?: PendingFile[]; + onRemovePendingFile?: (fileId: string) => void; + dataSources?: EditorDataSource[]; + featureDataSources?: EditorFeatureDataSource[]; } export const Automation2FlowEditor: React.FC = ({ instanceId, language = 'de', initialWorkflowId, + pendingFiles, + onRemovePendingFile, + dataSources, + featureDataSources, }) => { const { request } = useApiRequest(); const { prompt: promptInput, PromptDialog } = usePrompt(); @@ -71,7 +84,7 @@ export const Automation2FlowEditor: React.FC = ({ const [error, setError] = useState(null); const [filter, setFilter] = useState(''); const [expandedCategories, setExpandedCategories] = useState>( - new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup']) + new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee']) ); const [canvasNodes, setCanvasNodes] = useState([]); const [canvasConnections, setCanvasConnections] = useState([]); @@ -289,8 +302,8 @@ export const Automation2FlowEditor: React.FC = ({ const loadWorkflows = useCallback(async () => { if (!instanceId) return; try { - const items = await fetchWorkflows(request, instanceId); - setWorkflows(items); + const result = await fetchWorkflows(request, instanceId); + setWorkflows(Array.isArray(result) ? result : result.items); } catch (e) { console.error(`${LOG} loadWorkflows failed`, e); } @@ -452,6 +465,42 @@ export const Automation2FlowEditor: React.FC = ({ } }, [request, instanceId, currentWorkflowId, loadVersions]); + // Template: save current workflow as template + const [templateSaving, setTemplateSaving] = useState(false); + const handleSaveAsTemplate = useCallback( + async (scope: AutoTemplateScope) => { + if (!instanceId || !currentWorkflowId) return; + setTemplateSaving(true); + try { + await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope); + setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse); + } catch (e: unknown) { + setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); + } finally { + setTemplateSaving(false); + } + }, + [request, instanceId, currentWorkflowId] + ); + + // Template: new workflow from template + const [templatePickerOpen, setTemplatePickerOpen] = useState(false); + const handleNewFromTemplate = useCallback( + async (templateId: string) => { + if (!instanceId) return; + try { + const wf = await copyTemplate(request, instanceId, templateId); + setWorkflows((prev) => [...prev, wf]); + setCurrentWorkflowId(wf.id); + if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations); + setTemplatePickerOpen(false); + } catch (e: unknown) { + setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); + } + }, + [request, instanceId, handleFromApiGraph] + ); + const renderSidebar = () => { if (loading) { return ( @@ -497,14 +546,54 @@ export const Automation2FlowEditor: React.FC = ({ const configurableSelected = selectedNode && - ['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) => + ['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) => selectedNode.type.startsWith(p) ); return (
- {renderSidebar()} + {/* Chat/Tracing panel - left side */} + {(chatPanelOpen || tracingRunId) && ( +
+
+ + + +
+
+ {chatPanelOpen && currentWorkflowId ? ( + handleLoad(currentWorkflowId)} + pendingFiles={pendingFiles} + onRemovePendingFile={onRemovePendingFile} + dataSources={dataSources} + featureDataSources={featureDataSources} + /> + ) : tracingRunId ? ( + + ) : null} +
+
+ )} + {/* Canvas area - center */}
= ({ onArchiveVersion={handleArchiveVersion} onCreateDraft={handleCreateDraft} versionLoading={versionLoading} + onSaveAsTemplate={handleSaveAsTemplate} + templateSaving={templateSaving} + onNewFromTemplate={() => setTemplatePickerOpen(true)} />
@@ -565,37 +657,9 @@ export const Automation2FlowEditor: React.FC = ({ )}
- {(chatPanelOpen || tracingRunId) && ( -
-
- - - -
-
- {chatPanelOpen && currentWorkflowId ? ( - - ) : tracingRunId ? ( - - ) : null} -
-
- )} + + {/* Node sidebar - right side */} + {renderSidebar()} = ({ invocations={invocations} onApply={handleApplyWorkflowConfiguration} /> + setTemplatePickerOpen(false)} + onSelect={handleNewFromTemplate} + instanceId={instanceId} + request={request} + />
); }; diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx index 56e3dfb..735d419 100644 --- a/src/components/FlowEditor/editor/CanvasHeader.tsx +++ b/src/components/FlowEditor/editor/CanvasHeader.tsx @@ -2,9 +2,9 @@ * CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result. */ -import React from 'react'; -import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive } from 'react-icons/fa'; -import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion } from '../../../api/workflowApi'; +import React, { useState, useRef, useEffect } from 'react'; +import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaRobot, FaBookmark, FaCaretDown } from 'react-icons/fa'; +import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi'; import styles from './Automation2FlowEditor.module.css'; interface CanvasHeaderProps { @@ -28,6 +28,9 @@ interface CanvasHeaderProps { onArchiveVersion?: (versionId: string) => void; onCreateDraft?: () => void; versionLoading?: boolean; + onSaveAsTemplate?: (scope: AutoTemplateScope) => void; + templateSaving?: boolean; + onNewFromTemplate?: () => void; } const STATUS_BADGE: Record = { @@ -57,11 +60,31 @@ export const CanvasHeader: React.FC = ({ onArchiveVersion, onCreateDraft, versionLoading, + onSaveAsTemplate, + templateSaving, + onNewFromTemplate, }) => { const currentVersion = versions?.find((v) => v.id === currentVersionId); const currentStatus = currentVersion?.status || 'draft'; const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.draft; + const [newMenuOpen, setNewMenuOpen] = useState(false); + const newMenuRef = useRef(null); + + const [templateMenuOpen, setTemplateMenuOpen] = useState(false); + const templateMenuRef = useRef(null); + + useEffect(() => { + const _handleClickOutside = (e: MouseEvent) => { + if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false); + if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false); + }; + document.addEventListener('mousedown', _handleClickOutside); + return () => document.removeEventListener('mousedown', _handleClickOutside); + }, []); + + const SCOPE_LABELS: Record = { user: 'Meine Vorlagen', instance: 'Instanz', mandate: 'Mandant' }; + return (
@@ -79,9 +102,45 @@ export const CanvasHeader: React.FC = ({ )} - + + {/* Split "Neu" button */} +
+
+ + +
+ {newMenuOpen && ( +
+ + {onNewFromTemplate && ( + + )} +
+ )} +
+ + + {/* Save as template */} + {currentWorkflowId && onSaveAsTemplate && ( +
+ + {templateMenuOpen && ( +
+ {(['user', 'instance', 'mandate'] as const).map((s) => ( + + ))} +
+ )} +
+ )}