395 lines
15 KiB
TypeScript
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>
|
|
);
|
|
};
|