ui-nyla/src/components/FlowEditor/nodes/shared/DataPicker.tsx

619 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts, or from
* outputPorts[n].dataPickOptions when the backend defines an explicit list (authoritative).
* Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section.
*/
import React, { useEffect, useMemo, useRef, 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 { DataPickOption, GraphDataSources, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { fetchGraphDataSources } from '../../../../api/workflowApi';
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<string, unknown> }>;
nodeOutputsPreview: Record<string, unknown>;
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;
/** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */
typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
}
const _LIST_INNER_RE = /^List\[(.+)\]$/;
function _fieldSegHuman(field: PortField): string {
const picker = field.pickerLabel;
if (typeof picker === 'string' && picker.trim()) return picker.trim();
return field.name;
}
function _detailFromField(description: unknown): string | undefined {
if (typeof description === 'string' && description.trim()) return description.trim();
return undefined;
}
function _buildPathsFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0,
): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return [];
const result: PickablePath[] = [];
// For form schemas (kind=fromGraph), expose the whole `payload` object as a
// top-level pickable entry so the user can pass the entire form at once.
if (depth === 0 && schema.name?.startsWith('FormPayload')) {
result.push({
path: ['payload'],
label: 'Gesamtes Formular',
type: 'object',
recommended: true,
});
}
for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name];
const label =
baseSegments.length > 0
? `${baseSegments.join(' ')} ${segHuman}`
: segHuman;
const detail = _detailFromField(field.description);
result.push({
path: fieldPath,
label,
type: field.type,
recommended: field.recommended ?? false,
detail,
});
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
}
}
result.push({
path: [...basePath, '_success'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Erfolgskennzeichen` : '_success',
type: 'bool',
});
result.push({
path: [...basePath, '_error'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Fehlermeldung` : '_error',
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<string, unknown> },
paramKey: string,
formTypeToPort: Record<string, string> = {},
): PortSchema | undefined {
const resolvePortType = (rawType: string) => formTypeToPort[rawType] ?? rawType;
const raw = node.parameters?.[paramKey];
if (!Array.isArray(raw)) return undefined;
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const rec = item as Record<string, unknown>;
if (typeof rec.name !== 'string') continue;
const lab = rec.label;
let description: string | Record<string, string> = rec.name;
if (typeof lab === 'string') description = lab;
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
if (rawType === 'group' && Array.isArray(rec.fields)) {
for (const sub of rec.fields as Record<string, unknown>[]) {
if (!sub || typeof sub.name !== 'string') continue;
const sl = sub.label;
let sdesc: string | Record<string, string> = `${rec.name}.${sub.name}`;
if (typeof sl === 'string') sdesc = sl;
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
fields.push({
name: `${rec.name}.${sub.name}`,
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
description: sdesc,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: resolvePortType(rawType),
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<string, unknown>)) {
if (k.startsWith('_')) continue;
result.push(..._buildPathsFromPreview(v, [...basePath, k], wholeOutputLabel));
}
return result;
}
return [{ path: [...basePath], label: pathLabel }];
}
/** Gateway ``outputPorts[n].dataPickOptions`` — authoritative; no client-side catalog merge. */
function _pathsFromDataPickOptions(options: DataPickOption[]): PickablePath[] {
return options.map((o) => ({
path: [...o.path],
label: o.pickerLabel,
type: o.type,
recommended: Boolean(o.recommended),
iterable: Boolean(o.iterable),
detail: typeof o.detail === 'string' ? o.detail.trim() : undefined,
}));
}
function _resolveSchemaForNode(
nodeId: string,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
nodeTypes: NodeType[],
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
catalog: Record<string, PortSchema>,
visited: Set<string> = new Set(),
formTypeToPort: Record<string, string> = {},
): 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, formTypeToPort);
}
if (port0.dynamic && port0.deriveFrom) {
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom, formTypeToPort);
}
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, formTypeToPort);
}
export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClose,
onPick,
availableSourceIds,
nodes,
nodeOutputsPreview,
getNodeLabel,
expectedParamType,
}) => {
const { t } = useLanguage();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(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>(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 nodesRaw = ctx?.nodes ?? [];
// sourceHandle is a flat handle index (inputs first, then outputs).
// The backend expects sourceOutput as an output-port index (0-based after inputs).
const nodeInputsById = useMemo(
() => new Map(nodesRaw.map((n) => [n.id, n.inputs ?? 0])),
[nodesRaw],
);
const connections = useMemo(
() =>
connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle - (nodeInputsById.get(c.sourceId) ?? 0),
targetInput: c.targetHandle,
})),
[connectionsRaw, nodeInputsById],
);
// Fetch scope data from the backend when the picker opens — zero topology logic in JS.
const [scopeData, setScopeData] = useState<GraphDataSources | null>(null);
const scopeFetchKey = useRef<string>('');
useEffect(() => {
if (!open || !ctx?.instanceId || !ctx?.request || !ctx?.currentNodeId) return;
const key = `${ctx.instanceId}:${ctx.currentNodeId}:${connections.length}:${(ctx.nodes ?? []).length}`;
if (scopeFetchKey.current === key) return; // already fetched for this state
scopeFetchKey.current = key;
const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type }));
fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections)
.then(setScopeData)
.catch(() => setScopeData(null));
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
// Derived: effective source ids and loop context — use backend result when available,
// fall back to the prop (e.g. in tests or offline).
const effectiveSourceIds = scopeData?.availableSourceIds ?? availableSourceIds;
const portIndexOverrides = scopeData?.portIndexOverrides ?? {};
const loopBodyContextIds = scopeData?.loopBodyContextIds ?? [];
if (!open) return null;
const catalog = ctx?.portTypeCatalog ?? {};
const systemVars = ctx?.systemVariables ?? {};
const nodeTypes = ctx?.nodeTypes ?? [];
const formTypeToPort: Record<string, string> = Object.fromEntries(
(ctx?.formFieldTypes ?? []).map((f) => [f.id, f.portType])
);
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 = (
<div
className={styles.dataPickerOverlay}
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
>
<div
className={styles.dataPickerModal}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="automation2DataPickerTitle"
>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle} id="automation2DataPickerTitle">
{t('Datenquelle wählen')}
{expectedParamType && (
<span
className={styles.dataPickerTypeBadge}
title={t('Erwarteter Typ')}
>
{expectedParamType}
</span>
)}
</h4>
<div className={styles.dataPickerHeaderControls}>
{expectedParamType && (
<label className={styles.dataPickerStrictLabel}>
<input
type="checkbox"
checked={strictFilter}
onChange={(e) => setStrictFilter(e.target.checked)}
/>
{t('Nur kompatible')}
</label>
)}
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
×
</button>
</div>
</div>
<div className={styles.dataPickerBody}>
{/* System Variables Section */}
{loopBodyContextIds.length > 0 && (
<div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div>
<div className={styles.dataPickerTree}>
{loopBodyContextIds.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 (
<div key={loopId} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
{loopPaths.map((p, i) => {
const mismatch =
Boolean(expectedParamType) &&
Boolean(p.type) &&
isCompatible(p.type!, expectedParamType!) === 'mismatch';
return (
<button
key={`${loopId}-${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(loopId, p.path, p.type)}
>
{p.label}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{mismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
</div>
)}
{Object.keys(systemVars).length > 0 && (
<div className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
onClick={() => setShowSystem(!showSystem)}
>
<span className={styles.dataPickerExpandIcon}>{showSystem ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{t('System')}</span>
</button>
{showSystem && (
<div className={styles.dataPickerTree}>
{Object.entries(systemVars).map(([key, info]) => (
<button
key={key}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePickSystemVar(key)}
title={info.description}
>
{key} <span className={styles.dataPickerLeafType}>({info.type})</span>
</button>
))}
</div>
)}
</div>
)}
{/* Node outputs */}
{(() => {
const filteredIds = effectiveSourceIds.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 <p className={styles.dataPickerEmpty}>{t('Keine vorherigen Nodes verfügbar')}</p>;
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
// User-defined step title (or node-type label as fallback)
const stepTitle = node ? getNodeLabel(node) : nodeId;
const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined;
// Human-readable type label (e.g. "Formular", "Web-Recherche")
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId);
// Use the port index the backend says to use (e.g. 1 for loop on Done branch)
const portIdx = portIndexOverrides[nodeId] ?? 0;
const portDef = nodeTypeDef?.outputPorts?.[portIdx];
const backendPick =
portDef?.dataPickOptions &&
Array.isArray(portDef.dataPickOptions) &&
portDef.dataPickOptions.length > 0;
let schemaPaths: PickablePath[];
if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(portDef!.dataPickOptions!);
} else {
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates(
schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType,
);
const markedPaths = annotated.map((p) => ({
...p,
typeMismatch:
strictFilter &&
Boolean(expectedParamType) &&
Boolean(p.type) &&
!p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch',
}));
const orderedPaths = [
...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended),
];
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
{typeLabel && (
<span className={styles.dataPickerNodeSchemaHint}>
{typeLabel}
</span>
)}
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')}
</div>
)}
{orderedPaths.map((p, i) => (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<button
type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
title={p.detail || p.label}
>
{p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
{p.iterable && (
<button
type="button"
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
title={t('Pro Element der Liste iterieren (Loop)')}
>
{t('iterieren')}
</button>
)}
</div>
))}
</div>
)}
</div>
);
});
})()}
</div>
</div>
</div>
);
return createPortal(_dialog, document.body);
};