/** * NodeConfigPanel - Generic parameter renderer for all node types. * Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType. */ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import type { CanvasNode } from './FlowCanvas'; import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowApi'; import { getLabel } from '../nodes/shared/utils'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext'; import styles from './Automation2FlowEditor.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; interface NodeConfigPanelProps { node: CanvasNode | null; nodeType: NodeType | undefined; language: string; onParametersChange: (nodeId: string, parameters: Record) => void; onMergeNodeParameters?: (nodeId: string, patch: Record) => void; onNodeUpdate?: (nodeId: string, updates: Partial>) => void; instanceId?: string; request?: ApiRequestFunction; /** When true, render developer-oriented sections (Schema-Typ-Referenz, * parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */ verboseSchema?: boolean; } export const NodeConfigPanel: React.FC = ({ node, nodeType, language, onParametersChange, onMergeNodeParameters: _onMergeNodeParameters, onNodeUpdate, instanceId, request, verboseSchema = false, }) => { const { t } = useLanguage(); const [params, setParams] = useState>({}); const nodeIdRef = useRef(undefined); nodeIdRef.current = node?.id; const notifyParentTimeoutRef = useRef | null>(null); useEffect(() => { setParams(node?.parameters ?? {}); }, [node?.id, node?.parameters]); useEffect(() => { return () => { if (notifyParentTimeoutRef.current != null) { clearTimeout(notifyParentTimeoutRef.current); notifyParentTimeoutRef.current = null; } }; }, [node?.id]); const updateParam = useCallback( (key: string, value: unknown) => { setParams((prev) => { const next = { ...prev, [key]: value }; const id = nodeIdRef.current; if (id) { if (notifyParentTimeoutRef.current != null) { clearTimeout(notifyParentTimeoutRef.current); } notifyParentTimeoutRef.current = setTimeout(() => { notifyParentTimeoutRef.current = null; onParametersChange(id, next); }, 0); } return next; }); }, [onParametersChange] ); const dataFlow = useAutomation2DataFlow(); const portTypeCatalog: Record = (dataFlow?.portTypeCatalog as Record | undefined) ?? {}; // Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User // nicht nach unten scrollen muss, um zu sehen was fehlt. const sortedParameters: NodeTypeParameter[] = useMemo(() => { const all = nodeType?.parameters ?? []; const required = all.filter((p) => p.required); const optional = all.filter((p) => !p.required); return [...required, ...optional]; }, [nodeType?.parameters]); // Pre-compute which required params are unbound on this node so we can // surface a panel-level summary banner. The hidden-param safety net lives // inside `findRequiredErrors` so banner, canvas badges and Run-button stay // in lockstep. // Banner labels are kept short (`param.name`); the full description is // attached as the tooltip below. const requiredErrors = useMemo(() => { if (!node || !nodeType) return []; return findRequiredErrors(node, nodeType, (p) => p.name); }, [node, nodeType]); // Resolve full descriptions per missing param (for the banner tooltip). const requiredErrorTooltip = useMemo(() => { if (!requiredErrors.length || !nodeType) return ''; const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p])); return requiredErrors .map((e) => { const p = byName.get(e.paramName); const desc = p ? (getLabel(p.description, language) || '') : ''; return desc ? `${e.paramName}: ${desc}` : e.paramName; }) .join('\n'); }, [requiredErrors, nodeType, language]); if (!node || !nodeType) return null; const isTrigger = node.type.startsWith('trigger.'); const showNameField = onNodeUpdate && !isTrigger; const parameters = sortedParameters; const inputPortDefs = nodeType.inputPorts ?? {}; const outputPortDefs = nodeType.outputPorts ?? {}; const inputPortEntries = Object.entries(inputPortDefs); const outputPortEntries = Object.entries(outputPortDefs); const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0; return (
{showNameField && (
onNodeUpdate(node.id, { title: e.target.value })} placeholder={t('z.B. Kundenformular prüfen, Land')} />

{t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}

)}

{getLabel(nodeType?.label, language) || node.type}

{nodeType?.description && (

{getLabel(nodeType.description, language)}

)} {hasPortInfo && verboseSchema && (
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')} {inputPortEntries.length > 0 && (
{'\u2B07'} {t('Eingabe')}
{inputPortEntries.map(([idx, def]) => ( <_PortFieldList key={`in-${idx}`} portIndex={Number(idx)} schemaNames={def?.accepts ?? []} catalog={portTypeCatalog} emptyLabel={t('keine Felder')} language={language} /> ))}
)} {outputPortEntries.length > 0 && (
{'\u2B06'} {t('Ausgabe')}
{outputPortEntries.map(([idx, def]) => ( <_PortFieldList key={`out-${idx}`} portIndex={Number(idx)} schemaNames={_schemaNamesFromOutputPort(def)} catalog={portTypeCatalog} emptyLabel={t('keine Felder')} language={language} /> ))}
)}
)} {requiredErrors.length > 0 && (
{t('Pflicht-Felder ohne Quelle:')}{' '} {requiredErrors.map((e) => e.paramLabel).join(', ')}
)} {parameters.map((param: NodeTypeParameter) => { // Safety net: hidden params have no UI footprint at all — no row, // no required-mark, no type-badge. Their value is system-set. if (param.frontendType === 'hidden') return null; const useRequiredPicker = _shouldUseRequiredPicker(param); if (useRequiredPicker) { return (
updateParam(param.name, val)} />
); } const frontendType = param.frontendType || 'text'; const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; return (
{param.required && ( * )} {verboseSchema && param.type && ( {param.type} )}
updateParam(param.name, val)} allParams={params} instanceId={instanceId} request={request} nodeType={node.type} />
); })}
); }; /** Heuristic: required params with a Schicht-1 catalog type (non-primitive * ref/record/list) get the typed RequiredAttributePicker; primitive scalars * fall through to the legacy frontend-type renderer (text/number/select etc.) * unless they have no frontendType at all and a non-trivial type. */ function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean { if (!param.required) return false; if (!param.type) return false; // Hidden params never get a picker — they are system-set or rendered to // nothing on purpose. The render loop above also skips hidden rows entirely. if (param.frontendType === 'hidden') return false; // Always defer to specialized FE renderers when explicitly chosen. if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) { return false; } // Catalog ref/record/list types are best handled by RequiredAttributePicker. if (/^(List\[|Dict\[)/.test(param.type)) return true; if (/^[A-Z]/.test(param.type)) return true; return false; } const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([ 'userConnection', 'featureInstance', 'sharepointFolder', 'sharepointFile', 'clickupList', 'clickupTask', 'dataRef', 'caseList', 'fieldBuilder', 'keyValueRows', 'cron', 'condition', 'mappingTable', 'filterExpression', 'attachmentBuilder', 'json', ]); function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] { if (!def?.schema) return []; if (typeof def.schema === 'string') return [def.schema]; if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic']; return []; } interface _PortFieldListProps { portIndex: number; schemaNames: string[]; catalog: Record; emptyLabel: string; language: string; } const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => { if (!schemaNames.length) return null; return (
{`#${portIndex} `}{schemaNames.join(' | ')}
{schemaNames.map((name) => { const schema = catalog[name]; const fields = schema?.fields ?? []; if (name === 'Transit') { return (
{'\u00B7 Transit (durchgereichte Daten)'}
); } if (!fields.length) { return (
{`\u00B7 ${emptyLabel}`}
); } return (
    {fields.map((f) => (
  • {f.name} {`: ${f.type}`} {!f.required && {' (optional)'}} {f.description && (
    {getLabel(f.description, language)}
    )}
  • ))}
); })}
); };