diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 47c33c9..26e2367 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -144,6 +144,8 @@ export interface Automation2Workflow { label: string; graph: Automation2Graph; active?: boolean; + /** Target feature instance for execution data scope (NULL for templates) */ + targetFeatureInstanceId?: string | null; /** Entry points (Starts) — how this workflow may be invoked */ invocations?: WorkflowEntryPoint[]; /** Enriched: run count */ @@ -412,7 +414,12 @@ export async function fetchWorkflow( export async function createWorkflow( request: ApiRequestFunction, instanceId: string, - body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] } + body: { + label: string; + graph: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + targetFeatureInstanceId?: string | null; + } ): Promise { return await request({ url: `/api/workflows/${instanceId}/workflows`, @@ -431,6 +438,7 @@ export async function updateWorkflow( invocations?: WorkflowEntryPoint[]; active?: boolean; notifyOnFailure?: boolean; + targetFeatureInstanceId?: string | null; } ): Promise { return await request({ @@ -986,3 +994,95 @@ export async function loadClickupListTasksForDropdown( acc.sort((a, b) => a.name.localeCompare(b.name, 'de')); return acc; } + + +// ============================================================================ +// AUTOMATION WORKSPACE API (user-facing run workspace) +// ============================================================================ + +export interface WorkspaceRun { + id: string; + workflowId: string; + workflowLabel?: string; + status: string; + startedAt?: number; + completedAt?: number; + ownerId?: string; + mandateId?: string; + mandateLabel?: string; + targetFeatureInstanceId?: string; + targetInstanceLabel?: string; + costTokens?: number; + costCredits?: number; + error?: string; +} + +export interface WorkspaceRunDetail { + run: WorkspaceRun & { nodeOutputs?: Record }; + workflow: { + id: string; + label: string; + targetFeatureInstanceId?: string; + featureInstanceId?: string; + tags?: string[]; + } | null; + steps: Array<{ + id: string; + runId: string; + nodeId: string; + nodeType: string; + status: string; + inputSnapshot?: Record; + output?: Record; + inputFiles?: Array<{ id: string; fileName?: string }>; + outputFiles?: Array<{ id: string; fileName?: string }>; + error?: string; + startedAt?: number; + completedAt?: number; + durationMs?: number; + tokensUsed?: number; + retryCount?: number; + }>; + files: Array<{ + id: string; + fileName?: string; + contentType?: string; + sizeBytes?: number; + }>; + unassignedFiles?: Array<{ + id: string; + fileName?: string; + }>; +} + +export async function fetchWorkspaceRuns( + request: ApiRequestFunction, + params: { + scope?: 'mine' | 'mandate'; + status?: string; + targetInstanceId?: string; + workflowId?: string; + limit?: number; + offset?: number; + } = {}, +): Promise<{ runs: WorkspaceRun[]; total: number }> { + const query = new URLSearchParams(); + if (params.scope) query.set('scope', params.scope); + if (params.status) query.set('status', params.status); + if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId); + if (params.workflowId) query.set('workflowId', params.workflowId); + if (params.limit) query.set('limit', String(params.limit)); + if (params.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + const url = `/api/automations/runs${qs ? `?${qs}` : ''}`; + const resp = await request({ url, method: 'get' }); + return resp as { runs: WorkspaceRun[]; total: number }; +} + +export async function fetchWorkspaceRunDetail( + request: ApiRequestFunction, + runId: string, +): Promise { + const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' }); + return resp as WorkspaceRunDetail; +} diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index c5c7b85..f2ddf7a 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -59,6 +59,7 @@ import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { useToast } from '../../../contexts/ToastContext'; +import { useFeatureStore } from '../../../stores/featureStore'; const LOG = '[Automation2]'; @@ -133,6 +134,15 @@ export const Automation2FlowEditor: React.FC = ({ in const [currentVersionId, setCurrentVersionId] = useState(null); const [versionLoading, setVersionLoading] = useState(false); + const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState(instanceId); + const featureStore = useFeatureStore(); + const targetInstanceOptions = useMemo(() => { + const allInstances = featureStore.getAllInstances(); + return allInstances + .filter((inst) => inst.mandateId === mandateId || !mandateId) + .map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id })); + }, [featureStore, mandateId]); + const [leftPanelWidth, setLeftPanelWidth] = useState(() => { try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } }); @@ -297,7 +307,7 @@ export const Automation2FlowEditor: React.FC = ({ in setSaving(true); try { if (currentWorkflowId) { - await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); + await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId }); setExecuteResult(_buildSaveResult()); } else { const label = await promptInput(t('Workflow-Name:'), { @@ -313,6 +323,7 @@ export const Automation2FlowEditor: React.FC = ({ in label: label.trim() || t('Neuer Workflow'), graph, invocations, + targetFeatureInstanceId, }); setCurrentWorkflowId(created.id); if (created.invocations?.length) setInvocations(created.invocations); @@ -324,7 +335,7 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]); const handleLoad = useCallback( async (workflowId: string) => { @@ -335,6 +346,7 @@ export const Automation2FlowEditor: React.FC = ({ in } else { applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); } + setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId); setWorkflows((prev) => { const idx = prev.findIndex((w) => w.id === workflowId); if (idx === -1) return [...prev, wf]; @@ -661,6 +673,17 @@ export const Automation2FlowEditor: React.FC = ({ in [request, instanceId, handleFromApiGraph] ); + const handleTargetInstanceChange = useCallback(async (newTargetId: string) => { + setTargetFeatureInstanceId(newTargetId || null); + if (currentWorkflowId && newTargetId) { + try { + await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId }); + } catch (e: unknown) { + console.error(`${LOG} target instance update failed`, e); + } + } + }, [request, instanceId, currentWorkflowId]); + const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => { try { await updateWorkflow(request, instanceId, workflowId, { label: newName }); @@ -836,6 +859,9 @@ export const Automation2FlowEditor: React.FC = ({ in onAutoLayout={handleAutoLayout} verboseSchema={verboseSchema} onVerboseSchemaChange={setVerboseSchema} + targetFeatureInstanceId={targetFeatureInstanceId} + onTargetInstanceChange={handleTargetInstanceChange} + targetInstanceOptions={targetInstanceOptions} />
diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx index 0d82bff..f5b7509 100644 --- a/src/components/FlowEditor/editor/CanvasHeader.tsx +++ b/src/components/FlowEditor/editor/CanvasHeader.tsx @@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { getUserDataCache } from '../../../utils/userCache'; +interface TargetInstanceOption { + id: string; + label: string; +} + interface CanvasHeaderProps { workflows: Automation2Workflow[]; currentWorkflowId: string | null; @@ -45,6 +50,9 @@ interface CanvasHeaderProps { * "Schema (Typ-Referenz)" block and per-parameter type-badges. */ verboseSchema?: boolean; onVerboseSchemaChange?: (next: boolean) => void; + targetFeatureInstanceId?: string | null; + onTargetInstanceChange?: (instanceId: string) => void; + targetInstanceOptions?: TargetInstanceOption[]; } function _getStatusBadge(t: (key: string) => string): Record { @@ -84,6 +92,9 @@ export const CanvasHeader: React.FC = ({ workflows, onAutoLayout, verboseSchema, onVerboseSchemaChange, + targetFeatureInstanceId, + onTargetInstanceChange, + targetInstanceOptions, }) => { const { t } = useLanguage(); const _isSysAdmin = getUserDataCache()?.isSysAdmin === true; @@ -209,6 +220,21 @@ export const CanvasHeader: React.FC = ({ workflows, )} + {targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && ( + + )}
diff --git a/src/components/FlowEditor/editor/NodeConfigPanel.tsx b/src/components/FlowEditor/editor/NodeConfigPanel.tsx index b9799f2..ce6f3f1 100644 --- a/src/components/FlowEditor/editor/NodeConfigPanel.tsx +++ b/src/components/FlowEditor/editor/NodeConfigPanel.tsx @@ -332,6 +332,7 @@ const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([ 'filterExpression', 'attachmentBuilder', 'json', + 'modelMultiSelect', ]); function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] { diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx new file mode 100644 index 0000000..684e14e --- /dev/null +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/TemplateTextareaRenderer.tsx @@ -0,0 +1,171 @@ +/** + * TemplateTextarea — Freitext mit eingebetteten {{nodeId.path}} Tokens. + * Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway). + */ + +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import type { FieldRendererProps } from './index'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { DataPicker } from '../shared/DataPicker'; +import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef'; +import { useLanguage } from '../../../../providers/language/LanguageContext'; +import styles from '../../editor/Automation2FlowEditor.module.css'; + +const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g; + +function _refToTemplateToken(ref: DataRef): string { + const pathSegs = (ref.path ?? []).map((p) => String(p)); + if (pathSegs.length === 0) { + return `{{${ref.nodeId}}}`; + } + return `{{${ref.nodeId}.${pathSegs.join('.')}}}`; +} + +function _insertAtCursor( + text: string, + insert: string, + start: number, + end: number, +): { next: string; caret: number } { + const next = text.slice(0, start) + insert + text.slice(end); + const caret = start + insert.length; + return { next, caret }; +} + +function _parseTokensInTemplate( + template: string, + nodes: Array<{ id: string; title?: string }>, + getNodeLabel: (n: { id: string; title?: string }) => string, +): Array<{ raw: string; label: string }> { + const out: Array<{ raw: string; label: string }> = []; + const seen = new Set(); + let m: RegExpExecArray | null; + const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g'); + while ((m = re.exec(template)) !== null) { + const inner = m[1].trim(); + if (seen.has(inner)) continue; + seen.add(inner); + const parts = inner.split('.'); + const nodeId = parts[0]; + if (!nodeId) continue; + const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg)); + const ref: DataRef = { type: 'ref', nodeId, path }; + const label = formatRefLabel(ref, nodes, (id) => + getNodeLabel(nodes.find((n) => n.id === id) ?? { id }), + ); + out.push({ raw: m[0], label }); + } + return out; +} + +export const TemplateTextareaRenderer: React.FC = ({ param, value, onChange }) => { + const { t } = useLanguage(); + const dataFlow = useAutomation2DataFlow(); + const textareaRef = useRef(null); + const [pickerOpen, setPickerOpen] = useState(false); + + const strVal = typeof value === 'string' ? value : value != null ? String(value) : ''; + + const sourceIds = dataFlow?.getAvailableSourceIds() ?? []; + const hasSources = sourceIds.some((id) => { + const n = dataFlow?.nodes.find((x) => x.id === id); + return n?.type !== 'trigger.manual'; + }); + + const tokenLegend = useMemo(() => { + if (!dataFlow || !strVal.includes('{{')) return []; + return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel); + }, [strVal, dataFlow]); + + const handlePick = useCallback( + (picked: DataRef | SystemVarRef) => { + if (isSystemVar(picked)) { + setPickerOpen(false); + return; + } + if (!isRef(picked)) { + setPickerOpen(false); + return; + } + const token = _refToTemplateToken(picked); + const el = textareaRef.current; + const start = el?.selectionStart ?? strVal.length; + const end = el?.selectionEnd ?? strVal.length; + const { next, caret } = _insertAtCursor(strVal, token, start, end); + onChange(next); + setPickerOpen(false); + requestAnimationFrame(() => { + const ta = textareaRef.current; + if (ta) { + ta.focus(); + ta.setSelectionRange(caret, caret); + } + }); + }, + [onChange, strVal], + ); + + return ( +
+ +
+ + {!hasSources && ( + {t('Keine vorherigen Nodes verfügbar')} + )} +
+