/** * Automation2 Flow Editor - Schema-based Data Picker. * Builds pickable paths from portTypeCatalog + node outputPorts, or from * outputPorts[n].dataPickOptions when the backend defines an explicit list (authoritative). * Resolves Transit chains to show the real upstream schema. * Includes a System Variables section. */ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import type { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; import { fetchGraphDataSources } from '../../../../api/workflowApi'; import styles from '../../editor/Automation2FlowEditor.module.css'; import { useLanguage } from '../../../../providers/language/LanguageContext'; interface DataPickerProps { open: boolean; onClose: () => void; onPick: (ref: DataRef | SystemVarRef) => void; availableSourceIds: string[]; nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record }>; nodeOutputsPreview: Record; getNodeLabel: (node: { id: string; title?: string }) => string; /** When set, the picker can hide incompatible candidates (strict toggle) and * surfaces "Iterieren als Loop" affordances for List[X]→X candidates. */ expectedParamType?: string; } interface PickablePath { path: (string | number)[]; label: string; type?: string; /** True iff this path produces `List[X]` and the consumer expects `X` — * picking with iterate=true appends the wildcard segment. */ iterable?: boolean; /** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */ typeMismatch?: boolean; /** Surfaced at the top of the list as the most common / recommended pick. */ recommended?: boolean; /** Tooltip (Katalog oder Backend-Hinweistext). */ detail?: string; } const _LIST_INNER_RE = /^List\[(.+)\]$/; function _fieldSegHuman(field: PortField): string { const picker = field.pickerLabel; if (typeof picker === 'string' && picker.trim()) return picker.trim(); return field.name; } function _detailFromField(description: unknown): string | undefined { if (typeof description === 'string' && description.trim()) return description.trim(); return undefined; } function _buildPathsFromSchema( schema: PortSchema | undefined, catalog: Record, basePath: (string | number)[] = [], baseSegments: string[] = [], depth = 0, ): PickablePath[] { if (!schema || !schema.fields || depth > 8) return []; const result: PickablePath[] = []; // For form schemas (kind=fromGraph), expose the whole `payload` object as a // top-level pickable entry so the user can pass the entire form at once. if (depth === 0 && schema.name?.startsWith('FormPayload')) { result.push({ path: ['payload'], label: 'Gesamtes Formular', type: 'object', recommended: true, }); } for (const field of schema.fields) { const segHuman = _fieldSegHuman(field); const fieldPath = [...basePath, field.name]; const label = baseSegments.length > 0 ? `${baseSegments.join(' › ')} › ${segHuman}` : segHuman; const detail = _detailFromField(field.description); result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false, detail, }); const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; const inner = m?.[1]?.trim(); if (inner && catalog[inner]) { const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : ''; const itemBridge = pil || '*'; const nextSegments = [...baseSegments, segHuman, itemBridge]; result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1)); } } result.push({ path: [...basePath, '_success'], label: baseSegments.length > 0 ? `${baseSegments.join(' › ')} › Erfolgskennzeichen` : '_success', type: 'bool', }); result.push({ path: [...basePath, '_error'], label: baseSegments.length > 0 ? `${baseSegments.join(' › ')} › Fehlermeldung` : '_error', type: 'str', }); return result; } /** Annotate each candidate with `iterable=true` if it is `List[X]` and the * consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */ function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] { if (!expectedParamType) return paths; return paths.map((p) => { if (!p.type) return p; const m = p.type.match(_LIST_INNER_RE); if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true }; return p; }); } function _deriveFormPortSchemaFromParams( node: { parameters?: Record }, paramKey: string, formTypeToPort: Record = {}, ): PortSchema | undefined { const resolvePortType = (rawType: string) => formTypeToPort[rawType] ?? rawType; const raw = node.parameters?.[paramKey]; if (!Array.isArray(raw)) return undefined; const fields: Array<{ name: string; type: string; description: string | Record; required: boolean }> = []; for (const item of raw) { if (typeof item !== 'object' || item === null) continue; const rec = item as Record; if (typeof rec.name !== 'string') continue; const lab = rec.label; let description: string | Record = rec.name; if (typeof lab === 'string') description = lab; else if (lab && typeof lab === 'object') description = lab as Record; const rawType = typeof rec.type === 'string' ? rec.type : 'str'; if (rawType === 'group' && Array.isArray(rec.fields)) { for (const sub of rec.fields as Record[]) { if (!sub || typeof sub.name !== 'string') continue; const sl = sub.label; let sdesc: string | Record = `${rec.name}.${sub.name}`; if (typeof sl === 'string') sdesc = sl; else if (sl && typeof sl === 'object') sdesc = sl as Record; fields.push({ name: `${rec.name}.${sub.name}`, type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'), description: sdesc, required: Boolean(sub.required), }); } continue; } fields.push({ name: rec.name, type: resolvePortType(rawType), description, required: Boolean(rec.required), }); } return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined; } function _buildPathsFromPreview( obj: unknown, basePath: (string | number)[] = [], wholeOutputLabel = '(ganze Ausgabe)', ): PickablePath[] { const pathLabel = basePath.length ? basePath.map(String).join(' → ') : wholeOutputLabel; if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') { return [{ path: [...basePath], label: pathLabel }]; } if (Array.isArray(obj)) { const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }]; for (let i = 0; i < Math.min(obj.length, 5); i++) { result.push(..._buildPathsFromPreview(obj[i], [...basePath, i], wholeOutputLabel)); } return result; } if (typeof obj === 'object') { const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }]; for (const [k, v] of Object.entries(obj as Record)) { if (k.startsWith('_')) continue; result.push(..._buildPathsFromPreview(v, [...basePath, k], wholeOutputLabel)); } return result; } return [{ path: [...basePath], label: pathLabel }]; } /** Gateway ``outputPorts[n].dataPickOptions`` — authoritative; no client-side catalog merge. */ function _pathsFromDataPickOptions(options: DataPickOption[]): PickablePath[] { return options.map((o) => ({ path: [...o.path], label: o.pickerLabel, type: o.type, recommended: Boolean(o.recommended), iterable: Boolean(o.iterable), detail: typeof o.detail === 'string' ? o.detail.trim() : undefined, })); } function _resolveSchemaForNode( nodeId: string, nodes: Array<{ id: string; type?: string; parameters?: Record }>, nodeTypes: NodeType[], connections: Array<{ source: string; target: string; sourceOutput?: number }>, catalog: Record, visited: Set = new Set(), formTypeToPort: Record = {}, ): PortSchema | undefined { if (visited.has(nodeId)) return undefined; visited.add(nodeId); const node = nodes.find((n) => n.id === nodeId); if (!node) return undefined; const typeDef = nodeTypes.find((nt) => nt.id === node.type); if (!typeDef?.outputPorts) return undefined; const port0 = typeDef.outputPorts[0] as { schema?: string | GraphDefinedSchemaRef; dynamic?: boolean; deriveFrom?: string; }; if (!port0) return undefined; const schemaSpec = port0.schema; if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') { const paramKey = schemaSpec.parameter ?? 'fields'; return _deriveFormPortSchemaFromParams(node, paramKey, formTypeToPort); } if (port0.dynamic && port0.deriveFrom) { return _deriveFormPortSchemaFromParams(node, port0.deriveFrom, formTypeToPort); } if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') { return catalog[schemaSpec]; } // Transit: follow the incoming connection to find the real producer const incoming = connections.find((c) => c.target === nodeId); if (!incoming) return undefined; return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited, formTypeToPort); } export const DataPicker: React.FC = ({ open, onClose, onPick, availableSourceIds, nodes, nodeOutputsPreview, getNodeLabel, expectedParamType, }) => { const { t } = useLanguage(); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [showSystem, setShowSystem] = useState(false); // Default: when the consumer declares an expected type, show only compatible // candidates ("strict" mode). User can override per-session via the toggle. const [strictFilter, setStrictFilter] = useState(Boolean(expectedParamType)); const ctx = useAutomation2DataFlow(); // NOTE: All hooks must be called unconditionally on every render to satisfy // the Rules of Hooks. The `if (!open) return null;` early-return therefore // has to live BELOW every hook in this component. Adding a useMemo (or any // other hook) below it would change the hook count when the picker toggles // open/closed and crash the whole tree (white screen). const connectionsRaw = ctx?.connections ?? []; const nodesRaw = ctx?.nodes ?? []; // sourceHandle is a flat handle index (inputs first, then outputs). // The backend expects sourceOutput as an output-port index (0-based after inputs). const nodeInputsById = useMemo( () => new Map(nodesRaw.map((n) => [n.id, n.inputs ?? 0])), [nodesRaw], ); const connections = useMemo( () => connectionsRaw.map((c) => ({ source: c.sourceId, target: c.targetId, sourceOutput: c.sourceHandle - (nodeInputsById.get(c.sourceId) ?? 0), targetInput: c.targetHandle, })), [connectionsRaw, nodeInputsById], ); // Fetch scope data from the backend when the picker opens — zero topology logic in JS. const [scopeData, setScopeData] = useState(null); const scopeFetchKey = useRef(''); useEffect(() => { if (!open || !ctx?.instanceId || !ctx?.request || !ctx?.currentNodeId) return; const key = `${ctx.instanceId}:${ctx.currentNodeId}:${connections.length}:${(ctx.nodes ?? []).length}`; if (scopeFetchKey.current === key) return; // already fetched for this state scopeFetchKey.current = key; const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type })); fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections) .then(setScopeData) .catch(() => setScopeData(null)); }, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]); // Derived: effective source ids and loop context — use backend result when available, // fall back to the prop (e.g. in tests or offline). const effectiveSourceIds = scopeData?.availableSourceIds ?? availableSourceIds; const portIndexOverrides = scopeData?.portIndexOverrides ?? {}; const loopBodyContextIds = scopeData?.loopBodyContextIds ?? []; if (!open) return null; const catalog = ctx?.portTypeCatalog ?? {}; const systemVars = ctx?.systemVariables ?? {}; const nodeTypes = ctx?.nodeTypes ?? []; const formTypeToPort: Record = Object.fromEntries( (ctx?.formFieldTypes ?? []).map((f) => [f.id, f.portType]) ); const toggleExpand = (nodeId: string) => { setExpandedNodes((prev) => { const next = new Set(prev); if (next.has(nodeId)) next.delete(nodeId); else next.add(nodeId); return next; }); }; const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => { onPick(createRef(nodeId, path, expectedType)); onClose(); }; /** Loop-Vorschlag: for List[X]→X candidates, append the '*' wildcard so the * engine maps the consumer over each element (executionEngine wildcard). */ const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => { onPick(createRef(nodeId, [...path, '*'], expectedType)); onClose(); }; const handlePickSystemVar = (variable: string) => { onPick(createSystemVar(variable)); onClose(); }; const _dialog = (
e.key === 'Escape' && onClose()} role="presentation" >
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="automation2DataPickerTitle" >

{t('Datenquelle wählen')} {expectedParamType && ( {expectedParamType} )}

{expectedParamType && ( )}
{/* System Variables Section */} {loopBodyContextIds.length > 0 && (
{t('Schleife (lexikalisch)')}
{loopBodyContextIds.map((loopId) => { const loopNode = nodes.find((n) => n.id === loopId); const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; const loopSchema = catalog.LoopItem; const loopPaths = loopSchema ? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_')) : [ { path: ['currentItem'], label: 'currentItem', type: 'Any' }, { path: ['currentIndex'], label: 'currentIndex', type: 'int' }, { path: ['count'], label: 'count', type: 'int' }, ]; return (
{loopLabel}
{loopPaths.map((p, i) => { const mismatch = Boolean(expectedParamType) && Boolean(p.type) && isCompatible(p.type!, expectedParamType!) === 'mismatch'; return ( ); })}
); })}
)} {Object.keys(systemVars).length > 0 && (
{showSystem && (
{Object.entries(systemVars).map(([key, info]) => ( ))}
)}
)} {/* Node outputs */} {(() => { const filteredIds = effectiveSourceIds.filter((nodeId) => { const node = nodes.find((n) => n.id === nodeId); return node?.type !== 'trigger.manual'; }); if (filteredIds.length === 0 && Object.keys(systemVars).length === 0) { return

{t('Keine vorherigen Nodes verfügbar')}

; } return filteredIds.map((nodeId) => { const node = nodes.find((n) => n.id === nodeId); // User-defined step title (or node-type label as fallback) const stepTitle = node ? getNodeLabel(node) : nodeId; const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined; // Human-readable type label (e.g. "Formular", "Web-Recherche") const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const isExpanded = expandedNodes.has(nodeId); // Use the port index the backend says to use (e.g. 1 for loop on Done branch) const portIdx = portIndexOverrides[nodeId] ?? 0; const portDef = nodeTypeDef?.outputPorts?.[portIdx]; const backendPick = portDef?.dataPickOptions && Array.isArray(portDef.dataPickOptions) && portDef.dataPickOptions.length > 0; let schemaPaths: PickablePath[]; if (backendPick) { schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!); } else { const resolvedSchema = _resolveSchemaForNode( nodeId, nodes, nodeTypes, connections, catalog, new Set(), formTypeToPort, ); schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog); } const annotated = _markIterableCandidates( schemaPaths.length > 0 ? schemaPaths : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), expectedParamType, ); const markedPaths = annotated.map((p) => ({ ...p, typeMismatch: strictFilter && Boolean(expectedParamType) && Boolean(p.type) && !p.iterable && isCompatible(p.type!, expectedParamType!) === 'mismatch', })); const orderedPaths = [ ...markedPaths.filter((p) => p.recommended), ...markedPaths.filter((p) => !p.recommended), ]; return (
{isExpanded && (
{orderedPaths.length === 0 && (
{t('(keine Felder verfügbar)')}
)} {orderedPaths.map((p, i) => (
{p.iterable && ( )}
))}
)}
); }); })()}
); return createPortal(_dialog, document.body); };