frontend_nyla/src/components/FlowEditor/nodes/shared/DataPicker.tsx
2026-04-26 08:31:31 +02:00

482 lines
20 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.
* Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section.
*/
import React, { useMemo, 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 { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers';
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;
}
const _LIST_INNER_RE = /^List\[(.+)\]$/;
function _buildPathsFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
depth = 0,
): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return [];
const result: PickablePath[] = [];
for (const field of schema.fields) {
const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → ');
result.push({ path: fieldPath, label, type: field.type });
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
// Generic List drill-down: use '*' wildcard so the engine maps each item.
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
}
}
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), 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,
): PortSchema | undefined {
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 ftype = typeof rec.type === 'string' ? rec.type : 'str';
if (ftype === '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: typeof sub.type === 'string' ? sub.type : 'str',
description: sdesc,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: ftype,
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 }];
}
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(),
): 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);
}
if (port0.dynamic && port0.deriveFrom) {
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom);
}
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);
}
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 connections = useMemo(
() =>
connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
})),
[connectionsRaw],
);
const loopAncestorIds = useMemo(() => {
const cid = ctx?.currentNodeId;
if (!cid) return [] as string[];
return findLoopAncestorIds(nodes, connections, cid);
}, [ctx?.currentNodeId, nodes, connections]);
if (!open) return null;
const catalog = ctx?.portTypeCatalog ?? {};
const systemVars = ctx?.systemVariables ?? {};
const nodeTypes = ctx?.nodeTypes ?? [];
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 */}
{loopAncestorIds.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}>
{loopAncestorIds.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 compat = expectedParamType && p.type
? isCompatible(p.type, expectedParamType)
: 'ok';
return (
<button
key={`${loopId}-${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
style={{ opacity: compat === 'mismatch' ? 0.45 : 1 }}
onClick={() => handlePick(loopId, p.path, p.type)}
>
{p.label}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</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 = availableSourceIds.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);
const label = node ? getNodeLabel(node) : nodeId;
const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
);
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
const annotated = _markIterableCandidates(
schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType,
);
const paths = strictFilter && expectedParamType
? annotated.filter((p) => {
if (p.iterable) return true;
if (!p.type) return false;
return isCompatible(p.type, expectedParamType) !== 'mismatch';
})
: annotated;
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}>{label}</span>
{resolvedSchema && (
<span className={styles.dataPickerNodeSchemaHint}>
({resolvedSchema.name})
</span>
)}
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')}
</div>
)}
{paths.map((p, i) => {
const compat =
expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok';
return (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<button
type="button"
className={styles.dataPickerLeaf}
style={{ opacity: compat === 'mismatch' && !p.iterable ? 0.45 : 1, flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
>
{p.label}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</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);
};