diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 7a03b4e..9d7b6e7 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -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; diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index 1b32bfb..305039f 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -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; diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 772e9df..622d747 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -749,9 +749,20 @@ export const Automation2FlowEditor: React.FC = ({ 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 (
diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx index 2df5ba5..7f5f587 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx @@ -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 () => { diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index b4637fb..5c7a7c4 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -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, 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 }>, @@ -332,7 +381,7 @@ export const DataPicker: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ open, {isExpanded && (
- {paths.length === 0 && ( + {orderedPaths.length === 0 && (
{t('(keine Felder verfügbar)')}
)} - {paths.map((p, i) => { - return ( -
( +
+ + {p.iterable && ( - {p.iterable && ( - - )} -
- ); - })} + )} +
+ ))}
)}