/** * Automation2 Flow Editor - Schema-based Data Picker. * Builds pickable paths from portTypeCatalog + node outputPorts. * Resolves Transit chains to show the real upstream schema. * Includes a System Variables section. */ import React, { useMemo, 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 { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi'; import { findLoopAncestorIds } from './scopeHelpers'; 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; } const _LIST_INNER_RE = /^List\[(.+)\]$/; function _buildPathsFromSchema( schema: PortSchema | undefined, catalog: Record, basePath: (string | number)[] = [], depth = 0, ): PickablePath[] { if (!schema || !schema.fields || depth > 8) return []; const result: PickablePath[] = []; for (const field of schema.fields) { const fieldPath = [...basePath, field.name]; const label = fieldPath.map(String).join(' → '); result.push({ path: fieldPath, label, type: field.type }); const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; const inner = m?.[1]?.trim(); if (inner && catalog[inner]) { // Generic List drill-down: use '*' wildcard so the engine maps each item. result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); } } result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' }); result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), 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, ): PortSchema | undefined { 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 ftype = typeof rec.type === 'string' ? rec.type : 'str'; if (ftype === '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: typeof sub.type === 'string' ? sub.type : 'str', description: sdesc, required: Boolean(sub.required), }); } continue; } fields.push({ name: rec.name, type: ftype, 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 }]; } 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(), ): 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); } if (port0.dynamic && port0.deriveFrom) { return _deriveFormPortSchemaFromParams(node, port0.deriveFrom); } 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); } 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 connections = useMemo( () => connectionsRaw.map((c) => ({ source: c.sourceId, target: c.targetId, sourceOutput: c.sourceHandle, })), [connectionsRaw], ); const loopAncestorIds = useMemo(() => { const cid = ctx?.currentNodeId; if (!cid) return [] as string[]; return findLoopAncestorIds(nodes, connections, cid); }, [ctx?.currentNodeId, nodes, connections]); if (!open) return null; const catalog = ctx?.portTypeCatalog ?? {}; const systemVars = ctx?.systemVariables ?? {}; const nodeTypes = ctx?.nodeTypes ?? []; 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 */} {loopAncestorIds.length > 0 && (
{t('Schleife (lexikalisch)')}
{loopAncestorIds.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 compat = expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok'; return ( ); })}
); })}
)} {Object.keys(systemVars).length > 0 && (
{showSystem && (
{Object.entries(systemVars).map(([key, info]) => ( ))}
)}
)} {/* Node outputs */} {(() => { const filteredIds = availableSourceIds.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); const label = node ? getNodeLabel(node) : nodeId; const isExpanded = expandedNodes.has(nodeId); const resolvedSchema = _resolveSchemaForNode( nodeId, nodes, nodeTypes, connections, catalog, ); const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog); const annotated = _markIterableCandidates( schemaPaths.length > 0 ? schemaPaths : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), expectedParamType, ); const paths = strictFilter && expectedParamType ? annotated.filter((p) => { if (p.iterable) return true; if (!p.type) return false; return isCompatible(p.type, expectedParamType) !== 'mismatch'; }) : annotated; return (
{isExpanded && (
{paths.length === 0 && (
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
)} {paths.map((p, i) => { const compat = expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok'; return (
{p.iterable && ( )}
); })}
)}
); }); })()}
); return createPortal(_dialog, document.body); };