482 lines
20 KiB
TypeScript
482 lines
20 KiB
TypeScript
/**
|
||
* 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);
|
||
};
|