node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick

This commit is contained in:
Ida 2026-05-06 12:51:18 +02:00
parent 0941b9e0ad
commit 5ff75a63e3
5 changed files with 198 additions and 75 deletions

View file

@ -32,6 +32,10 @@ export interface PortField {
enumValues?: string[] | null; enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */ /** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean; recommended?: boolean;
/** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */
pickerLabel?: string | null;
/** Backend: segment for one list element (between List field and nested field). */
pickerItemLabel?: string | null;
} }
export interface PortSchema { export interface PortSchema {
@ -39,6 +43,20 @@ export interface PortSchema {
fields: PortField[]; fields: PortField[];
} }
/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */
export interface DataPickOption {
path: (string | number)[];
pickerLabel: string;
detail?: string;
recommended?: boolean;
iterable?: boolean;
/** For display and optional strict compatibility (e.g. str, Any). */
type?: string;
}
/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */
export type OutputPickHint = DataPickOption;
export interface InputPortDef { export interface InputPortDef {
accepts: string[]; accepts: string[];
} }
@ -53,6 +71,11 @@ export interface OutputPortDef {
schema: string | GraphDefinedSchemaRef; schema: string | GraphDefinedSchemaRef;
dynamic?: boolean; dynamic?: boolean;
deriveFrom?: string; deriveFrom?: string;
/**
* When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion).
* Authoritative, like `parameters` for node configuration.
*/
dataPickOptions?: DataPickOption[];
} }
export interface NodeType { export interface NodeType {
@ -76,7 +99,6 @@ export interface NodeType {
action?: string; action?: string;
}; };
} }
export interface NodeTypeCategory { export interface NodeTypeCategory {
id: string; id: string;
label: Record<string, string> | string; label: Record<string, string> | string;

View file

@ -1771,6 +1771,39 @@
border-color: var(--primary-color, #007bff); border-color: var(--primary-color, #007bff);
} }
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */ /* Dynamic Value Field */
.dynamicValueField { .dynamicValueField {
display: flex; display: flex;

View file

@ -749,9 +749,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected = const configurableSelected =
selectedNode && selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) => [
selectedNode.type.startsWith(p) 'input.',
); 'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
return ( return (
<div className={styles.container}> <div className={styles.container}>

View file

@ -123,11 +123,11 @@ function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRe
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('DataPicker — generic-object drill-down (T8)', () => { describe('DataPicker — generic-object drill-down (T8)', () => {
it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => { it('renders the wildcard "documents * name" path when drilling into List[UdmDocument]', async () => {
_renderPicker(); _renderPicker();
await userEvent.click(screen.getByText(/^up$/)); await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument(); expect(screen.getByText(/documents \* mimeType/)).toBeInTheDocument();
}); });
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => { it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
@ -137,7 +137,7 @@ describe('DataPicker — generic-object drill-down (T8)', () => {
expect(screen.getByText('count')).toBeInTheDocument(); expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument(); expect(screen.getByText('meta')).toBeInTheDocument();
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error). // Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
expect(screen.getAllByText(/documents \*/).length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText(/documents \*/).length).toBeGreaterThanOrEqual(2);
}); });
}); });
@ -150,14 +150,14 @@ describe('DataPicker — strict type filtering (T7)', () => {
_renderPicker({ expectedParamType: 'str' }); _renderPicker({ expectedParamType: 'str' });
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked(); expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
await userEvent.click(screen.getByText(/^up$/)); await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is a hard mismatch → must be hidden. // documents (List[UdmDocument]) is a hard mismatch → shown with warning (not removed).
expect(screen.queryByText('documents')).not.toBeInTheDocument(); expect(screen.getByText('documents')).toBeInTheDocument();
// meta (str) is exact match → kept. // meta (str) is exact match → kept.
expect(screen.getByText('meta')).toBeInTheDocument(); expect(screen.getByText('meta')).toBeInTheDocument();
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode). // count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
expect(screen.getByText('count')).toBeInTheDocument(); expect(screen.getByText('count')).toBeInTheDocument();
// Drilled wildcard candidates of type str (name, mimeType) remain. // Drilled wildcard candidates of type str (name, mimeType) remain.
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
}); });
it('shows all fields after the user disables the strict toggle', async () => { it('shows all fields after the user disables the strict toggle', async () => {

View file

@ -1,6 +1,7 @@
/** /**
* Automation2 Flow Editor - Schema-based Data Picker. * Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts. * 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. * Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section. * Includes a System Variables section.
*/ */
@ -9,7 +10,7 @@ import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi'; import type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers'; import { findLoopAncestorIds } from './scopeHelpers';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
@ -39,14 +40,28 @@ interface PickablePath {
typeMismatch?: boolean; typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */ /** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean; recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
} }
const _LIST_INNER_RE = /^List\[(.+)\]$/; 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( function _buildPathsFromSchema(
schema: PortSchema | undefined, schema: PortSchema | undefined,
catalog: Record<string, PortSchema>, catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [], basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0, depth = 0,
): PickablePath[] { ): PickablePath[] {
if (!schema || !schema.fields || depth > 8) return []; if (!schema || !schema.fields || depth > 8) return [];
@ -64,21 +79,43 @@ function _buildPathsFromSchema(
} }
for (const field of schema.fields) { for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name]; const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → '); const label =
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false }); 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 m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim(); const inner = m?.[1]?.trim();
if (inner && catalog[inner]) { if (inner && catalog[inner]) {
// Generic List drill-down: use '*' wildcard so the engine maps each item. const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
} }
} }
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' }); result.push({
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' }); 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; return result;
} }
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the /** Annotate each candidate with `iterable=true` if it is `List[X]` and the
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */ * consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] { function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
@ -162,6 +199,18 @@ function _buildPathsFromPreview(
return [{ path: [...basePath], label: pathLabel }]; 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( function _resolveSchemaForNode(
nodeId: string, nodeId: string,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>, nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
@ -332,7 +381,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem; const loopSchema = catalog.LoopItem;
const loopPaths = loopSchema const loopPaths = loopSchema
? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_')) ? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
: [ : [
{ path: ['currentItem'], label: 'currentItem', type: 'Any' }, { path: ['currentItem'], label: 'currentItem', type: 'Any' },
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' }, { path: ['currentIndex'], label: 'currentIndex', type: 'int' },
@ -423,24 +472,33 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
const typeLabel = nodeTypeDef?.label ?? node?.type ?? ''; const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId); const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode( const port0Def = nodeTypeDef?.outputPorts?.[0];
nodeId, const backendPick =
nodes, port0Def?.dataPickOptions &&
nodeTypes, Array.isArray(port0Def.dataPickOptions) &&
connections, port0Def.dataPickOptions.length > 0;
catalog,
new Set(), let schemaPaths: PickablePath[];
formTypeToPort, if (backendPick) {
); schemaPaths = _pathsFromDataPickOptions(port0Def!.dataPickOptions!);
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog); } else {
const resolvedSchema = _resolveSchemaForNode(
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates( const annotated = _markIterableCandidates(
schemaPaths.length > 0 schemaPaths.length > 0
? schemaPaths ? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')), : _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType, expectedParamType,
); );
// Always show all paths; mark mismatches as a visual warning instead of hiding them.
// Recommended entries bubble to the top.
const markedPaths = annotated.map((p) => ({ const markedPaths = annotated.map((p) => ({
...p, ...p,
typeMismatch: typeMismatch:
@ -450,7 +508,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
!p.iterable && !p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch', isCompatible(p.type!, expectedParamType!) === 'mismatch',
})); }));
const paths = [ const orderedPaths = [
...markedPaths.filter((p) => p.recommended), ...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended), ...markedPaths.filter((p) => !p.recommended),
]; ];
@ -472,56 +530,55 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</button> </button>
{isExpanded && ( {isExpanded && (
<div className={styles.dataPickerTree}> <div className={styles.dataPickerTree}>
{paths.length === 0 && ( {orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}> <div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')} {t('(keine Felder verfügbar)')}
</div> </div>
)} )}
{paths.map((p, i) => { {orderedPaths.map((p, i) => (
return ( <div
<div key={`${p.path.join('.')}-${i}`}
key={`${p.path.join('.')}-${i}`} style={{ display: 'flex', alignItems: 'center', gap: 4 }}
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 <button
type="button" type="button"
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`} className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
style={{ flex: 1 }} onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
onClick={() => handlePick(nodeId, p.path, p.type)} title={t('Pro Element der Liste iterieren (Loop)')}
> >
{p.label} {t('iterieren')}
{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> </button>
{p.iterable && ( )}
<button </div>
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>