frontend_nyla/src/components/FlowEditor/editor/NodeConfigPanel.tsx
2026-04-26 08:31:31 +02:00

395 lines
15 KiB
TypeScript

/**
* 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<string, unknown>) => void;
onMergeNodeParameters?: (nodeId: string, patch: Record<string, unknown>) => void;
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => 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<NodeConfigPanelProps> = ({ node,
nodeType,
language,
onParametersChange,
onMergeNodeParameters: _onMergeNodeParameters,
onNodeUpdate,
instanceId,
request,
verboseSchema = false,
}) => {
const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({});
const nodeIdRef = useRef<string | undefined>(undefined);
nodeIdRef.current = node?.id;
const notifyParentTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | 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 (
<div className={styles.nodeConfigPanel}>
{showNameField && (
<div className={styles.nodeConfigNameRow}>
<label htmlFor="node-config-name">{t('Bezeichnung')}</label>
<input
id="node-config-name"
type="text"
value={node.title ?? ''}
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
placeholder={t('z.B. Kundenformular prüfen, Land')}
/>
<p className={styles.nodeConfigNameHint}>
{t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}
</p>
</div>
)}
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
{nodeType?.description && (
<p className={styles.nodeConfigDescription}>
{getLabel(nodeType.description, language)}
</p>
)}
{hasPortInfo && verboseSchema && (
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
<summary
style={{
cursor: 'pointer',
color: 'var(--text-secondary)',
fontWeight: 500,
padding: '0.15rem 0',
fontStyle: 'italic',
}}
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
>
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
</summary>
{inputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B07'} {t('Eingabe')}
</div>
{inputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`in-${idx}`}
portIndex={Number(idx)}
schemaNames={def?.accepts ?? []}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
{outputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B06'} {t('Ausgabe')}
</div>
{outputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`out-${idx}`}
portIndex={Number(idx)}
schemaNames={_schemaNamesFromOutputPort(def)}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
</details>
)}
{requiredErrors.length > 0 && (
<div
style={{
marginBottom: 8,
padding: '6px 10px',
background: 'rgba(220,53,69,0.10)',
borderLeft: '3px solid var(--danger-color, #dc3545)',
borderRadius: 4,
fontSize: 12,
color: 'var(--danger-color, #dc3545)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
title={requiredErrorTooltip || undefined}
>
{t('Pflicht-Felder ohne Quelle:')}{' '}
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{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 (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={params[param.name] ?? param.default}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={param.name} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
/>
</div>
);
})}
</div>
);
};
/** 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<string, PortSchema>;
emptyLabel: string;
language: string;
}
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
if (!schemaNames.length) return null;
return (
<div style={{ marginLeft: 4, marginBottom: 4 }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
{`#${portIndex} `}{schemaNames.join(' | ')}
</div>
{schemaNames.map((name) => {
const schema = catalog[name];
const fields = schema?.fields ?? [];
if (name === 'Transit') {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
{'\u00B7 Transit (durchgereichte Daten)'}
</div>
);
}
if (!fields.length) {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
{`\u00B7 ${emptyLabel}`}
</div>
);
}
return (
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
{fields.map((f) => (
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
{f.description && (
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
{getLabel(f.description, language)}
</div>
)}
</li>
))}
</ul>
);
})}
</div>
);
};