node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick
This commit is contained in:
parent
0941b9e0ad
commit
5ff75a63e3
5 changed files with 198 additions and 75 deletions
|
|
@ -32,6 +32,10 @@ export interface PortField {
|
|||
enumValues?: string[] | null;
|
||||
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
|
||||
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 {
|
||||
|
|
@ -39,6 +43,20 @@ export interface PortSchema {
|
|||
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 {
|
||||
accepts: string[];
|
||||
}
|
||||
|
|
@ -53,6 +71,11 @@ export interface OutputPortDef {
|
|||
schema: string | GraphDefinedSchemaRef;
|
||||
dynamic?: boolean;
|
||||
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 {
|
||||
|
|
@ -76,7 +99,6 @@ export interface NodeType {
|
|||
action?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeTypeCategory {
|
||||
id: string;
|
||||
label: Record<string, string> | string;
|
||||
|
|
|
|||
|
|
@ -1771,6 +1771,39 @@
|
|||
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 */
|
||||
.dynamicValueField {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -749,9 +749,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
const configurableSelected =
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
|
|
|
|||
|
|
@ -123,11 +123,11 @@ function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRe
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
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();
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/documents › \* › name/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/documents › \* › mimeType/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('meta')).toBeInTheDocument();
|
||||
// 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' });
|
||||
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
|
||||
await userEvent.click(screen.getByText(/^up$/));
|
||||
// documents (List[UdmDocument]) is a hard mismatch → must be hidden.
|
||||
expect(screen.queryByText('documents')).not.toBeInTheDocument();
|
||||
// documents (List[UdmDocument]) is a hard mismatch → shown with warning (not removed).
|
||||
expect(screen.getByText('documents')).toBeInTheDocument();
|
||||
// meta (str) is exact match → kept.
|
||||
expect(screen.getByText('meta')).toBeInTheDocument();
|
||||
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
// 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 () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* 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.
|
||||
* Includes a System Variables section.
|
||||
*/
|
||||
|
|
@ -9,7 +10,7 @@ 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 type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
|
||||
import { findLoopAncestorIds } from './scopeHelpers';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
|
||||
|
|
@ -39,14 +40,28 @@ interface PickablePath {
|
|||
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 [];
|
||||
|
|
@ -64,21 +79,43 @@ function _buildPathsFromSchema(
|
|||
}
|
||||
|
||||
for (const field of schema.fields) {
|
||||
const segHuman = _fieldSegHuman(field);
|
||||
const fieldPath = [...basePath, field.name];
|
||||
const label = fieldPath.map(String).join(' → ');
|
||||
result.push({ path: fieldPath, label, type: field.type, recommended: field.recommended ?? false });
|
||||
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]) {
|
||||
// Generic List drill-down: use '*' wildcard so the engine maps each item.
|
||||
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
|
||||
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: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
|
||||
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
|
||||
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[] {
|
||||
|
|
@ -162,6 +199,18 @@ function _buildPathsFromPreview(
|
|||
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> }>,
|
||||
|
|
@ -332,7 +381,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
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('_'))
|
||||
? _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' },
|
||||
|
|
@ -423,24 +472,33 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
|
||||
const isExpanded = expandedNodes.has(nodeId);
|
||||
|
||||
const resolvedSchema = _resolveSchemaForNode(
|
||||
nodeId,
|
||||
nodes,
|
||||
nodeTypes,
|
||||
connections,
|
||||
catalog,
|
||||
new Set(),
|
||||
formTypeToPort,
|
||||
);
|
||||
const schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
|
||||
const port0Def = nodeTypeDef?.outputPorts?.[0];
|
||||
const backendPick =
|
||||
port0Def?.dataPickOptions &&
|
||||
Array.isArray(port0Def.dataPickOptions) &&
|
||||
port0Def.dataPickOptions.length > 0;
|
||||
|
||||
let schemaPaths: PickablePath[];
|
||||
if (backendPick) {
|
||||
schemaPaths = _pathsFromDataPickOptions(port0Def!.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,
|
||||
);
|
||||
// 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) => ({
|
||||
...p,
|
||||
typeMismatch:
|
||||
|
|
@ -450,7 +508,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
!p.iterable &&
|
||||
isCompatible(p.type!, expectedParamType!) === 'mismatch',
|
||||
}));
|
||||
const paths = [
|
||||
const orderedPaths = [
|
||||
...markedPaths.filter((p) => p.recommended),
|
||||
...markedPaths.filter((p) => !p.recommended),
|
||||
];
|
||||
|
|
@ -472,56 +530,55 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
|||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.dataPickerTree}>
|
||||
{paths.length === 0 && (
|
||||
{orderedPaths.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
|
||||
{t('(keine Felder verfügbar)')}
|
||||
</div>
|
||||
)}
|
||||
{paths.map((p, i) => {
|
||||
return (
|
||||
<div
|
||||
key={`${p.path.join('.')}-${i}`}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
{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}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => handlePick(nodeId, p.path, p.type)}
|
||||
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
|
||||
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
|
||||
title={t('Pro Element der Liste iterieren (Loop)')}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{t('iterieren')}
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue