/** * 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'; import { AccordionList } from '../../UiComponents/AccordionList'; import type { AccordionListItem } from '../../UiComponents/AccordionList'; const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent'; const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const; const CONTEXT_EXTRACT_CHUNK_SET = new Set(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES); /** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */ export function workflowParamUiValue(stored: Record, param: NodeTypeParameter): unknown { const raw = stored[param.name]; if (param.required) { return raw !== undefined && raw !== null ? raw : param.default; } return raw; } function effectiveSchemaParamString(name: string, currentParams: Record, nt: NodeType): string { const raw = currentParams[name]; const s = raw !== undefined && raw !== null ? String(raw) : ''; if (s !== '') return s; const meta = nt.parameters?.find((p) => p.name === name); const d = meta?.default; return d !== undefined && d !== null ? String(d) : ''; } function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode { return ( {param.required ? ( * ) : null} {param.name} ); } function verboseSchemaTypeBadge( verboseSchema: boolean, param: NodeTypeParameter, t: (key: string) => string, ): React.ReactElement | null { if (!verboseSchema || !param.type) return null; return (
{param.type}
); } 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; } /** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set * (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the * parameter unless the referenced parameter's effective value matches. */ export function parameterVisibleForFrontendOptions( param: NodeTypeParameter, params: Record, nodeType: NodeType, ): boolean { const fo = param.frontendOptions; if (!fo || typeof fo !== 'object') return true; const dependsOnRaw = fo.dependsOn as unknown; const showWhenRaw = fo.showWhen as unknown; if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) { return true; } const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw); const rawSibling = params[dependsOnRaw]; const siblingValue = rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : ''; const fallback = depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : ''; const effective = siblingValue !== '' ? siblingValue : fallback; const allowed: string[] = Array.isArray(showWhenRaw) ? showWhenRaw.map((x) => String(x)) : [String(showWhenRaw)]; return allowed.includes(effective); } 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 }; if (value === undefined) { delete next[key]; } else { next[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 patchParams = useCallback( (patch: Record) => { setParams((prev) => { const next = { ...prev, ...patch }; 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]); const extractContentAccordionItems = useMemo((): AccordionListItem[] | null => { if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null; const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p])); const out: AccordionListItem[] = []; for (const param of sortedParameters) { if (param.frontendType === 'hidden') continue; if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue; const usePicker = _shouldUseRequiredPicker(param); if (usePicker) { out.push({ id: param.name, title: accordionExtractParamTitle(param, t), children: (
{verboseSchemaTypeBadge(verboseSchema, param, t)} updateParam(param.name, val)} />
), }); continue; } const frontendType = param.frontendType || 'text'; const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; if (param.name === 'outputMode') { const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks'; out.push({ id: param.name, title: accordionExtractParamTitle(param, t), children: (
{verboseSchemaTypeBadge(verboseSchema, param, t)} updateParam(param.name, val)} allParams={params} instanceId={instanceId} request={request} nodeType={node.type} onPatchParams={patchParams} hideAccordionTitle /> {chunksNested ? (
key={`extract-chunks-${node.id}`} defaultOpenId={null} items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem => { const cp = byName.get(chunkName); if (!cp) { return { id: chunkName, title: chunkName, children: <> }; } const ft = cp.frontendType || 'text'; const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text; return { id: chunkName, title: accordionExtractParamTitle(cp, t), children: (
{verboseSchemaTypeBadge(verboseSchema, cp, t)} updateParam(cp.name, val)} allParams={params} instanceId={instanceId} request={request} nodeType={node.type} onPatchParams={patchParams} hideAccordionTitle />
), }; })} />
) : null}
), }); continue; } out.push({ id: param.name, title: accordionExtractParamTitle(param, t), children: (
{verboseSchemaTypeBadge(verboseSchema, param, t)} updateParam(param.name, val)} allParams={params} instanceId={instanceId} request={request} nodeType={node.type} onPatchParams={patchParams} hideAccordionTitle />
), }); } return out; }, [ sortedParameters, params, nodeType, language, node?.id, node?.type, verboseSchema, instanceId, request, patchParams, updateParam, t, ]); 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(', ')}
)} {extractContentAccordionItems !== null ? ( key={`${node.id}-extract-accordion`} defaultOpenId={null} items={extractContentAccordionItems} /> ) : ( 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; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) 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} onPatchParams={patchParams} />
); }) )}
); }; /** 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', 'userFileFolder', 'clickupList', 'clickupTask', 'dataRef', 'caseList', 'fieldBuilder', 'keyValueRows', 'cron', 'condition', 'mappingTable', 'filterExpression', 'attachmentBuilder', 'json', 'modelMultiSelect', ]); 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)}
    )}
  • ))}
); })}
); };